From 7de3dd09f1ba5a9a9e8d0e4a9f2def0e964f9f90 Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Wed, 9 Sep 2026 15:16:31 +0000 Subject: [PATCH 01/15] COSE signatures now service map Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/audit/builtin_maps.rst | 6 +- src/endpoints/endpoint_registry.cpp | 2 +- src/node/historical_queries.h | 49 ++++++++++---- src/node/historical_queries_adapter.cpp | 9 ++- src/node/history.h | 9 +-- src/node/node_state.h | 11 ++-- src/node/rpc/node_frontend.h | 2 +- src/node/signature_cache_interface.h | 2 +- src/node/signature_cache_subsystem.h | 19 +++--- src/node/snapshot_serdes.h | 4 +- src/node/snapshotter.h | 31 ++++----- src/node/test/historical_queries.cpp | 88 +++++++++++++++++++++++++ src/node/test/history.cpp | 82 +++++++++++++++++++++++ src/node/test/snapshotter.cpp | 3 +- src/node/tx_receipt_impl.h | 7 +- src/service/tables/signatures.h | 23 ++++++- 16 files changed, 287 insertions(+), 60 deletions(-) diff --git a/doc/audit/builtin_maps.rst b/doc/audit/builtin_maps.rst index b100eae08ea3..eb6fc03d8e1d 100644 --- a/doc/audit/builtin_maps.rst +++ b/doc/audit/builtin_maps.rst @@ -494,11 +494,11 @@ Signatures emitted by the primary node at regular interval, over the root of the ``cose_signatures`` ~~~~~~~~~~~~~~~~~~~ -COSE signatures emitted by the primary node over the root of the Merkle Tree at that sequence number. +COSE signatures over the Merkle root, keyed by service signing identity type. -**Key** Sentinel value 0, represented as a little-endian 64-bit unsigned integer. +**Key** Identity type as a little-endian 64-bit unsigned integer: ``CLASSICAL`` (0), ``PQ`` (1). Only ``CLASSICAL`` is populated. -**Value** Raw COSE Sign1 message as byte string (DER-encoded). Implements the following :ccf_repo:`CDDL schema `. +**Value** A CBOR-encoded COSE Sign1 message, stored as a base64-encoded JSON string. Implements the following :ccf_repo:`CDDL schema `. ``recovery_shares`` ~~~~~~~~~~~~~~~~~~~ diff --git a/src/endpoints/endpoint_registry.cpp b/src/endpoints/endpoint_registry.cpp index 5a06825c847a..0fc8d2a78c90 100644 --- a/src/endpoints/endpoint_registry.cpp +++ b/src/endpoints/endpoint_registry.cpp @@ -269,7 +269,7 @@ namespace ccf::endpoints return std::make_shared( sig, - cached_sig->cose_signature, + cached_sig->cose_signatures, proof.get_root(), proof.get_path(), node, diff --git a/src/node/historical_queries.h b/src/node/historical_queries.h index 31423e8f4ca1..4db67a90525d 100644 --- a/src/node/historical_queries.h +++ b/src/node/historical_queries.h @@ -74,12 +74,31 @@ namespace ccf::historical return signatures->get(); } - static std::optional get_cose_signature( + static ccf::CoseSignatureMap get_cose_signatures( const ccf::kv::StorePtr& sig_store) { auto tx = sig_store->create_read_only_tx(); auto* signatures = tx.ro(ccf::Tables::COSE_SIGNATURES); - return signatures->get(); + ccf::CoseSignatureMap cose_signatures; + signatures->foreach( + [&cose_signatures](const auto& identity_type, const auto& signature) { + cose_signatures.emplace(identity_type, signature); + return true; + }); + return cose_signatures; + } + + // This historical API exposes a single COSE signature, so receipts are + // described by the ECDSA one. + static std::optional select_described_cose_signature( + const ccf::CoseSignatureMap& cose_signatures) + { + const auto signature = cose_signatures.find(ccf::IdentityType::CLASSICAL); + if (signature == cose_signatures.end()) + { + return std::nullopt; + } + return signature->second; } static std::optional> get_tree( @@ -497,8 +516,10 @@ namespace ccf::historical // Iterate through earlier indices. If this signature covers them // then create a receipt for them const auto sig = get_signature(sig_details->store); - const auto cose_sig = get_cose_signature(sig_details->store); - if (!sig.has_value() && !cose_sig.has_value()) + const auto cose_sigs = get_cose_signatures(sig_details->store); + const auto described_cose_sig = + select_described_cose_signature(cose_sigs); + if (!sig.has_value() && !described_cose_sig.has_value()) { return false; } @@ -533,7 +554,7 @@ namespace ccf::historical details->transaction_id = {sig->view, seqno}; details->receipt = std::make_shared( sig->sig, - cose_sig, + cose_sigs, proof.get_root(), proof.get_path(), sig->node, @@ -544,8 +565,8 @@ namespace ccf::historical } else { - auto cose_receipt = - ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + auto cose_receipt = ccf::cose::decode_ccf_receipt( + described_cose_sig.value(), false); auto parsed_txid = ccf::TxID::from_str(cose_receipt.phdr.ccf.txid); if (!parsed_txid.has_value()) @@ -557,7 +578,7 @@ namespace ccf::historical details->transaction_id = {parsed_txid->view, seqno}; details->receipt = std::make_shared( std::nullopt, - cose_sig, + cose_sigs, proof.get_root(), proof.get_path(), ccf::NodeId{}, @@ -842,17 +863,19 @@ namespace ccf::historical // the receipt _later_ for an already-fetched signature // transaction. const auto sig = get_signature(details->store); - const auto cose_sig = get_cose_signature(details->store); + const auto cose_sigs = get_cose_signatures(details->store); + const auto described_cose_sig = + select_described_cose_signature(cose_sigs); if (sig.has_value()) { details->transaction_id = {sig->view, sig->seqno}; details->receipt = std::make_shared( - sig->sig, cose_sig, sig->root.h, nullptr, sig->node, sig->cert); + sig->sig, cose_sigs, sig->root.h, nullptr, sig->node, sig->cert); } - else if (cose_sig.has_value()) + else if (described_cose_sig.has_value()) { auto as_receipt = - ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + ccf::cose::decode_ccf_receipt(described_cose_sig.value(), false); const auto& txid = as_receipt.phdr.ccf.txid; auto parsed_txid = ccf::TxID::from_str(txid); @@ -864,7 +887,7 @@ namespace ccf::historical details->transaction_id = parsed_txid.value(); details->receipt = std::make_shared( std::nullopt, - cose_sig, + cose_sigs, std::nullopt, nullptr, ccf::NodeId{}, diff --git a/src/node/historical_queries_adapter.cpp b/src/node/historical_queries_adapter.cpp index 9f9d41b29956..666b424c7cc6 100644 --- a/src/node/historical_queries_adapter.cpp +++ b/src/node/historical_queries_adapter.cpp @@ -269,7 +269,14 @@ namespace ccf std::optional describe_cose_signature_v1( const TxReceiptImpl& receipt) { - return receipt.cose_signature; + // This API exposes a single signature, so it returns the ECDSA one. + const auto signature = + receipt.cose_signatures.find(IdentityType::CLASSICAL); + if (signature == receipt.cose_signatures.end()) + { + return std::nullopt; + } + return signature->second; } std::optional describe_cose_receipt_v1( diff --git a/src/node/history.h b/src/node/history.h index e20ccef9069e..cc98472602eb 100644 --- a/src/node/history.h +++ b/src/node/history.h @@ -107,7 +107,7 @@ namespace ccf ccf::Tables::SERIALISED_MERKLE_TREE); PrimarySignature sig_value(id, txid.seqno); signatures->put(sig_value); - cose_signatures->put(ccf::CoseSignature{}); + cose_signatures->put(ccf::IdentityType::CLASSICAL, ccf::CoseSignature{}); serialised_tree->put({}); return sig.commit_reserved(); } @@ -424,7 +424,7 @@ namespace ccf } std::vector cose_sign(cose_buf.to_vector()); - cose_signatures->put(cose_sign); + cose_signatures->put(ccf::IdentityType::CLASSICAL, cose_sign); auto* serialised_tree = sig.template wo( ccf::Tables::SERIALISED_MERKLE_TREE); @@ -807,9 +807,10 @@ namespace ccf // verifying. auto* cose_signatures = tx.template ro(ccf::Tables::COSE_SIGNATURES); - auto cose_sig = cose_signatures->get(); + auto cose_sig = cose_signatures->get(ccf::IdentityType::CLASSICAL); const auto cose_sig_version = - cose_signatures->get_version_of_previous_write(); + cose_signatures->get_version_of_previous_write( + ccf::IdentityType::CLASSICAL); if ( cose_sig.has_value() && cose_sig_version.has_value() && cose_sig_version.value() == version) diff --git a/src/node/node_state.h b/src/node/node_state.h index c60aa82d8a79..88c110657e79 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -112,7 +112,8 @@ namespace ccf best_view = ls->view; } - auto lcs = tx.ro(Tables::COSE_SIGNATURES)->get(); + auto lcs = tx.ro(Tables::COSE_SIGNATURES) + ->get(ccf::IdentityType::CLASSICAL); if (lcs.has_value()) { auto receipt = cose::decode_ccf_receipt(lcs.value(), false); @@ -2036,7 +2037,8 @@ namespace ccf } ccf::COSESignaturesConfig cs_cfg{}; - auto lcs = tx.ro(network.cose_signatures)->get(); + auto lcs = + tx.ro(network.cose_signatures)->get(ccf::IdentityType::CLASSICAL); if (lcs.has_value()) { CoseSignature cs = lcs.value(); @@ -3630,8 +3632,9 @@ namespace ccf [s = this->snapshotter]( ccf::kv::Version version, const CoseSignatures::Write& w) -> ccf::kv::ConsensusHookPtr { - assert(w.has_value()); - s->record_cose_signature(version, w.value()); + const auto cose_signatures = extract_cose_signatures(w); + assert(!cose_signatures.empty()); + s->record_cose_signatures(version, cose_signatures); return {nullptr}; })); diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index bd466d8177b5..f9f470645b0e 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -717,7 +717,7 @@ namespace ccf ccf::kv::Version cose_seqno = 0; auto cose_signatures = args.tx.template ro(Tables::COSE_SIGNATURES); - auto cose_sig = cose_signatures->get(); + auto cose_sig = cose_signatures->get(ccf::IdentityType::CLASSICAL); if (cose_sig.has_value() && !cose_sig->empty()) { auto receipt = ccf::cose::decode_ccf_receipt(cose_sig.value(), false); diff --git a/src/node/signature_cache_interface.h b/src/node/signature_cache_interface.h index 5e3cf30f0067..b4b7679b87ba 100644 --- a/src/node/signature_cache_interface.h +++ b/src/node/signature_cache_interface.h @@ -14,7 +14,7 @@ namespace ccf struct CachedSignature { std::optional sig; - std::optional> cose_signature; + CoseSignatureMap cose_signatures; std::vector serialised_tree; ccf::SeqNo sig_seqno; }; diff --git a/src/node/signature_cache_subsystem.h b/src/node/signature_cache_subsystem.h index 370b59486e9f..b90167afa674 100644 --- a/src/node/signature_cache_subsystem.h +++ b/src/node/signature_cache_subsystem.h @@ -17,12 +17,12 @@ namespace ccf struct PendingEntry { std::optional sig = std::nullopt; - std::optional> cose_signature = std::nullopt; + CoseSignatureMap cose_signatures; std::optional> serialised_tree = std::nullopt; [[nodiscard]] bool is_complete() const { - return (sig.has_value() || cose_signature.has_value()) && + return (sig.has_value() || !cose_signatures.empty()) && serialised_tree.has_value(); } }; @@ -83,7 +83,7 @@ namespace ccf const auto& [version, entry] = *it; if ( - !(entry.sig.has_value() || entry.cose_signature.has_value()) || + !(entry.sig.has_value() || !entry.cose_signatures.empty()) || !entry.serialised_tree.has_value()) { return std::nullopt; @@ -91,7 +91,7 @@ namespace ccf return CachedSignature{ entry.sig, - entry.cose_signature, + entry.cose_signatures, entry.serialised_tree.value(), version}; } @@ -104,12 +104,12 @@ namespace ccf entry.sig = sig; } - void on_cose_signature_committed( - ccf::kv::Version version, const std::vector& cose_sig) + void on_cose_signatures_committed( + ccf::kv::Version version, const CoseSignatureMap& cose_signatures) { ccf::ds::MutexGuard guard(cache_mutex); auto& entry = get_or_create_entry(version); - entry.cose_signature = cose_sig; + entry.cose_signatures = cose_signatures; } void on_tree_committed( @@ -136,9 +136,10 @@ namespace ccf Tables::COSE_SIGNATURES, CoseSignatures::wrap_commit_hook( [this](ccf::kv::Version version, const CoseSignatures::Write& w) { - if (w.has_value()) + const auto cose_signatures = extract_cose_signatures(w); + if (!cose_signatures.empty()) { - on_cose_signature_committed(version, w.value()); + on_cose_signatures_committed(version, cose_signatures); } })); diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index a06a6556c734..9a032e483c90 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -415,7 +415,7 @@ namespace ccf } static std::vector build_and_serialise_receipt( - const std::vector& cose_sig, + const CoseSignatureMap& cose_sigs, const std::vector& tree, ccf::kv::Version seqno, const ccf::crypto::Sha256Hash& write_set_digest, @@ -429,7 +429,7 @@ namespace ccf cd.set(std::move(claims_digest)); ccf::TxReceiptImpl tx_receipt( {}, - cose_sig, + cose_sigs, proof.get_root(), proof.get_path(), {}, diff --git a/src/node/snapshotter.h b/src/node/snapshotter.h index 55a0a7ba65b0..85957723a0b9 100644 --- a/src/node/snapshotter.h +++ b/src/node/snapshotter.h @@ -71,7 +71,7 @@ namespace ccf std::optional<::consensus::Index> evidence_idx = std::nullopt; - std::optional> cose_sig = std::nullopt; + std::optional cose_sigs = std::nullopt; std::optional> tree = std::nullopt; // Outputs of the serialise action, handed to the persist action. @@ -203,7 +203,7 @@ namespace ccf std::shared_ptr self; ccf::kv::Version version; ::consensus::Index evidence_idx; - std::vector cose_sig; + CoseSignatureMap cose_sigs; std::vector tree; std::shared_ptr serialised; @@ -213,13 +213,13 @@ namespace ccf std::shared_ptr _self, ccf::kv::Version _version, ::consensus::Index _evidence_idx, - std::vector _cose_sig, + CoseSignatureMap _cose_sigs, std::vector _tree, std::shared_ptr _serialised) : self(std::move(_self)), version(_version), evidence_idx(_evidence_idx), - cose_sig(std::move(_cose_sig)), + cose_sigs(std::move(_cose_sigs)), tree(std::move(_tree)), serialised(std::move(_serialised)), name(fmt::format("persist-snapshot@{}", version)) @@ -228,7 +228,7 @@ namespace ccf void do_action() override { self->persist_snapshot_( - version, evidence_idx, cose_sig, tree, serialised); + version, evidence_idx, cose_sigs, tree, serialised); } [[nodiscard]] const std::string& get_name() const override @@ -310,12 +310,12 @@ namespace ccf void persist_snapshot_( ccf::kv::Version version, ::consensus::Index evidence_idx, - const std::vector& cose_sig, + const CoseSignatureMap& cose_sigs, const std::vector& tree, const std::shared_ptr& serialised) { auto serialised_receipt = build_and_serialise_receipt( - cose_sig, + cose_sigs, tree, evidence_idx, serialised->write_set_digest, @@ -365,7 +365,7 @@ namespace ccf if ( snapshot_info.evidence_idx.has_value() && idx > snapshot_info.evidence_idx.value() && - snapshot_info.cose_sig.has_value() && snapshot_info.tree.has_value()) + snapshot_info.cose_sigs.has_value() && snapshot_info.tree.has_value()) { // Commit evidence is durable. Enqueue the persist action on this // generation's ordered task collection. OrderedTasks guarantees it @@ -378,7 +378,7 @@ namespace ccf shared_from_this(), snapshot_info.version, snapshot_info.evidence_idx.value(), - std::move(snapshot_info.cose_sig.value()), + std::move(snapshot_info.cose_sigs.value()), std::move(snapshot_info.tree.value()), snapshot_info.serialised)); @@ -513,8 +513,8 @@ namespace ccf return false; } - void record_cose_signature( - ::consensus::Index idx, const std::vector& cose_sig) + void record_cose_signatures( + ::consensus::Index idx, const CoseSignatureMap& cose_sigs) { std::lock_guard guard(lock); @@ -523,16 +523,17 @@ namespace ccf if ( pending_snapshot.evidence_idx.has_value() && idx > pending_snapshot.evidence_idx.value() && - !pending_snapshot.cose_sig.has_value()) + !pending_snapshot.cose_sigs.has_value()) { LOG_TRACE_FMT( - "Recording COSE signature at {} for snapshot {} with evidence at " - "{}", + "Recording {} COSE signature(s) at {} for snapshot {} with " + "evidence at {}", + cose_sigs.size(), idx, pending_snapshot.version, pending_snapshot.evidence_idx.value()); - pending_snapshot.cose_sig = cose_sig; + pending_snapshot.cose_sigs = cose_sigs; } } } diff --git a/src/node/test/historical_queries.cpp b/src/node/test/historical_queries.cpp index c90dac444242..096f3da89764 100644 --- a/src/node/test/historical_queries.cpp +++ b/src/node/test/historical_queries.cpp @@ -20,6 +20,7 @@ #include "kv/test/stub_consensus.h" #include "node/history.h" #include "node/share_manager.h" +#include "node/signature_cache_subsystem.h" #include #include @@ -2196,6 +2197,93 @@ TEST_CASE("adjust_ranges") } } +TEST_CASE( + "Historical and cached COSE signatures belong to the same transaction") +{ + auto state = create_and_init_state(); + auto& store = *state.kv_store; + ccf::SignatureCacheSubsystem signature_cache; + signature_cache.register_hooks(store); + ccf::historical::StateCache historical_cache( + store, state.ledger_secrets, std::make_shared()); + + const std::vector signature_transactions{ + {{ccf::IdentityType::CLASSICAL, {1, 2}}, {ccf::IdentityType::PQ, {3, 4}}}, + {{ccf::IdentityType::CLASSICAL, {5, 6}}}, + {{ccf::IdentityType::PQ, {7, 8}}}}; + + for (const auto& written_signatures : signature_transactions) + { + auto tx = store.create_tx(); + auto* signatures = tx.wo(ccf::Tables::COSE_SIGNATURES); + for (const auto& [identity_type, signature] : written_signatures) + { + signatures->put(identity_type, signature); + } + tx.wo(ccf::Tables::SERIALISED_MERKLE_TREE) + ->put({}); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + + const auto txid = store.current_txid(); + INFO("Signature transaction: ", txid.to_str()); + { + auto read_tx = store.create_read_only_tx(); + REQUIRE( + read_tx.ro(ccf::Tables::COSE_SIGNATURES)->size() == + 2); + } + store.compact(txid.seqno); + const auto cached = signature_cache.get_signature_for(txid.seqno - 1); + REQUIRE(cached.has_value()); + REQUIRE(cached->sig_seqno == txid.seqno); + REQUIRE(cached->cose_signatures == written_signatures); + + const auto ledger = construct_host_ledger(store.get_consensus()); + const auto& entry = ledger.at(txid.seqno); + auto result = ccf::kv::ApplyResult::FAIL; + ccf::ClaimsDigest claims_digest; + bool has_commit_evidence = false; + const auto historical_store = historical_cache.deserialise_ledger_entry( + txid.seqno, + entry.data(), + entry.size(), + result, + claims_digest, + has_commit_evidence); + REQUIRE(historical_store != nullptr); + REQUIRE(result == ccf::kv::ApplyResult::PASS_SIGNATURE); + REQUIRE(historical_store->current_txid() == txid); + REQUIRE( + ccf::historical::get_cose_signatures(historical_store) == + cached->cose_signatures); + } +} + +TEST_CASE("Legacy COSE receipt descriptions select CLASSICAL, not PQ") +{ + const ccf::CoseSignature classical_signature{1, 2, 3}; + const ccf::CoseSignature pq_signature{4, 5, 6}; + ccf::TxReceiptImpl receipt( + std::nullopt, + {{ccf::IdentityType::CLASSICAL, classical_signature}, + {ccf::IdentityType::PQ, pq_signature}}, + std::nullopt, + nullptr, + ccf::NodeId{}, + std::nullopt); + + REQUIRE(ccf::describe_cose_signature_v1(receipt) == classical_signature); + REQUIRE( + ccf::historical::select_described_cose_signature(receipt.cose_signatures) == + classical_signature); + + receipt.cose_signatures.erase(ccf::IdentityType::CLASSICAL); + REQUIRE_FALSE(ccf::describe_cose_signature_v1(receipt).has_value()); + REQUIRE_FALSE( + ccf::historical::select_described_cose_signature(receipt.cose_signatures) + .has_value()); +} + int main(int argc, char** argv) { doctest::Context context; diff --git a/src/node/test/history.cpp b/src/node/test/history.cpp index 6e725c61f29f..695d9748f3bf 100644 --- a/src/node/test/history.cpp +++ b/src/node/test/history.cpp @@ -727,3 +727,85 @@ int main(int argc, char** argv) return res; return res; } + +TEST_CASE("COSE signature table holds one entry per identity") +{ + ccf::kv::Store store; + auto encryptor = std::make_shared(); + store.set_encryptor(encryptor); + + const ccf::CoseSignature ec384_sig{1, 2, 3}; + const ccf::CoseSignature mldsa65_sig{4, 5, 6}; + + INFO("A table carrying two identities round-trips both"); + { + auto tx = store.create_tx(); + auto* handle = tx.wo(ccf::Tables::COSE_SIGNATURES); + handle->put(ccf::IdentityType::CLASSICAL, ec384_sig); + handle->put(ccf::IdentityType::PQ, mldsa65_sig); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + { + auto tx = store.create_read_only_tx(); + auto* handle = tx.ro(ccf::Tables::COSE_SIGNATURES); + + // A reader which only understands ECDSA still finds it, unaffected by + // the presence of identities it does not know about. + REQUIRE(handle->get(ccf::IdentityType::CLASSICAL) == ec384_sig); + REQUIRE(handle->get(ccf::IdentityType::PQ) == mldsa65_sig); + + ccf::CoseSignatureMap read_back; + handle->foreach([&read_back](const auto& identity_type, const auto& sig) { + read_back.emplace(identity_type, sig); + return true; + }); + REQUIRE(read_back.size() == 2); + } +} + +TEST_CASE("CLASSICAL COSE signatures interoperate with the legacy singleton") +{ + using LegacyCoseSignatures = ccf::ServiceValue; + ccf::kv::Store store; + store.set_encryptor(std::make_shared()); + const ccf::CoseSignature legacy_signature{1, 2, 3}; + const ccf::CoseSignature keyed_signature{4, 5, 6}; + + { + auto tx = store.create_tx(); + tx.wo(ccf::Tables::COSE_SIGNATURES) + ->put(legacy_signature); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + { + auto tx = store.create_tx(); + auto* signatures = tx.rw(ccf::Tables::COSE_SIGNATURES); + REQUIRE(signatures->get(ccf::IdentityType::CLASSICAL) == legacy_signature); + signatures->put(ccf::IdentityType::CLASSICAL, keyed_signature); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + { + auto tx = store.create_read_only_tx(); + REQUIRE( + tx.ro(ccf::Tables::COSE_SIGNATURES)->get() == + keyed_signature); + } +} + +TEST_CASE("extract_cose_signatures skips removals") +{ + ccf::CoseSignatures::Write writes; + writes[ccf::IdentityType::CLASSICAL] = ccf::CoseSignature{1, 2, 3}; + // A removal is recorded as an unset value, and must not be reported as a + // signature. + writes[ccf::IdentityType::PQ] = std::nullopt; + + const auto extracted = ccf::extract_cose_signatures(writes); + REQUIRE(extracted.size() == 1); + REQUIRE( + extracted.at(ccf::IdentityType::CLASSICAL) == ccf::CoseSignature{1, 2, 3}); + REQUIRE_FALSE(extracted.contains(ccf::IdentityType::PQ)); +} diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 158d5f8fa045..100f7a458c72 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -380,7 +380,8 @@ bool record_signature( "b96881e8c6f9265af8"); bool requires_snapshot = snapshotter->record_committable(idx); - snapshotter->record_cose_signature(idx, dummy_cose_sig); + snapshotter->record_cose_signatures( + idx, {{ccf::IdentityType::CLASSICAL, dummy_cose_sig}}); snapshotter->record_serialised_tree(idx, history->serialise_tree(idx)); return requires_snapshot; diff --git a/src/node/tx_receipt_impl.h b/src/node/tx_receipt_impl.h index 2dd465b925c3..4e8e27c86146 100644 --- a/src/node/tx_receipt_impl.h +++ b/src/node/tx_receipt_impl.h @@ -5,6 +5,7 @@ #include "ccf/network_identity_interface.h" #include "ccf/receipt.h" #include "node/history.h" +#include "service/tables/signatures.h" namespace ccf { @@ -13,7 +14,7 @@ namespace ccf struct TxReceiptImpl { std::optional> signature; - std::optional> cose_signature = std::nullopt; + CoseSignatureMap cose_signatures; std::optional root; std::shared_ptr path; ccf::NodeId node_id; @@ -27,7 +28,7 @@ namespace ccf TxReceiptImpl( const std::optional>& signature_, - const std::optional>& cose_signature, + CoseSignatureMap cose_signatures_, const std::optional& root_, std::shared_ptr path_, NodeId node_id_, @@ -43,7 +44,7 @@ namespace ccf const std::optional& cose_endorsements_ = std::nullopt) : signature(signature_), - cose_signature(cose_signature), + cose_signatures(std::move(cose_signatures_)), root(root_), path(std::move(path_)), node_id(std::move(node_id_)), diff --git a/src/service/tables/signatures.h b/src/service/tables/signatures.h index cf9f8a758c50..94e6ff7d3dec 100644 --- a/src/service/tables/signatures.h +++ b/src/service/tables/signatures.h @@ -4,7 +4,9 @@ #include "ccf/service/map.h" #include "node_signature.h" +#include "service/tables/identity_types.h" +#include #include #include @@ -62,9 +64,26 @@ namespace ccf ccf::kv::RawCopySerialisedValue>; using CoseSignature = std::vector; + using CoseSignatureMap = std::map; - // Most recent COSE signature is a single Value in the KV - using CoseSignatures = ServiceValue; + // One COSE signature per service signing identity. CLASSICAL is 0, so its + // key serialises to the same 8 zero bytes as the single-value table which + // preceded multiple signing identities. + using CoseSignatures = ServiceMap; + + inline CoseSignatureMap extract_cose_signatures( + const CoseSignatures::Write& writes) + { + CoseSignatureMap signatures; + for (const auto& [identity_type, signature] : writes) + { + if (signature.has_value()) + { + signatures.emplace(identity_type, signature.value()); + } + } + return signatures; + } namespace Tables { From b7d6a5bbd35607e302fa72811619d2aa56fc34e9 Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Thu, 10 Sep 2026 11:58:59 +0000 Subject: [PATCH 02/15] Fixup build --- src/js/test/js.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index df40cb537ecf..04b753f93c4f 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -655,7 +655,7 @@ TEST_CASE("Historical state") auto store = std::make_shared(); auto receipt = std::make_shared( std::vector{1, 2, 3}, - std::nullopt, + ccf::CoseSignatureMap{}, ccf::HistoryTree::Hash{}, nullptr, ccf::NodeId("test-node"), From 6771a0de31b307e97af1e5a4b9ca98dc86aee60f Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Thu, 10 Sep 2026 13:04:09 +0000 Subject: [PATCH 03/15] Clang tidy --- src/service/tables/identity_types.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/service/tables/identity_types.h b/src/service/tables/identity_types.h index 5b215c441387..3efb114e6f0a 100644 --- a/src/service/tables/identity_types.h +++ b/src/service/tables/identity_types.h @@ -12,6 +12,8 @@ namespace ccf { + // Preserve the existing 64-bit C++ representation. + // NOLINTNEXTLINE(performance-enum-size) enum class IdentityType : uint64_t { CLASSICAL = 0, From ad3d58ccc3f37fe373fd079362888ddc839bfc2c Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Thu, 10 Sep 2026 14:08:17 +0000 Subject: [PATCH 04/15] Clang tidy! --- doc/audit/builtin_maps.rst | 2 +- src/node/signature_cache_subsystem.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/audit/builtin_maps.rst b/doc/audit/builtin_maps.rst index eb6fc03d8e1d..5e0f425477f0 100644 --- a/doc/audit/builtin_maps.rst +++ b/doc/audit/builtin_maps.rst @@ -496,7 +496,7 @@ Signatures emitted by the primary node at regular interval, over the root of the COSE signatures over the Merkle root, keyed by service signing identity type. -**Key** Identity type as a little-endian 64-bit unsigned integer: ``CLASSICAL`` (0), ``PQ`` (1). Only ``CLASSICAL`` is populated. +**Key** Identity type as a little-endian 64-bit unsigned integer. Only ``CLASSICAL`` (0) is currently populated; ``PQ`` (1) is reserved for future support. **Value** A CBOR-encoded COSE Sign1 message, stored as a base64-encoded JSON string. Implements the following :ccf_repo:`CDDL schema `. diff --git a/src/node/signature_cache_subsystem.h b/src/node/signature_cache_subsystem.h index b90167afa674..d8505f2243ac 100644 --- a/src/node/signature_cache_subsystem.h +++ b/src/node/signature_cache_subsystem.h @@ -83,7 +83,7 @@ namespace ccf const auto& [version, entry] = *it; if ( - !(entry.sig.has_value() || !entry.cose_signatures.empty()) || + (!entry.sig.has_value() && entry.cose_signatures.empty()) || !entry.serialised_tree.has_value()) { return std::nullopt; From 708e3c85fb7dc5b2008c685bc6c94b8d1095687c Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 11 Sep 2026 14:01:24 +0100 Subject: [PATCH 05/15] Validate the Lean disaster recovery model against Stateright (#8279) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: achamayou --- .github/workflows/README.md | 4 + .github/workflows/lean.yml | 53 +++ .../DisasterRecoveryMigration.lean | 3 + .../Legacy/Checker.lean | 152 +++++++ .../Legacy/Model.lean | 392 ++++++++++++++++++ .../DisasterRecoveryMigration/Refinement.lean | 335 +++++++++++++++ .../ExportMain.lean | 24 ++ lean/disaster-recovery-migration/Main.lean | 23 + lean/disaster-recovery-migration/README.md | 113 +++++ lean/disaster-recovery-migration/Tests.lean | 72 ++++ lean/disaster-recovery-migration/compare.py | 358 ++++++++++++++++ .../lake-manifest.json | 138 ++++++ .../disaster-recovery-migration/lakefile.toml | 31 ++ .../lean-toolchain | 1 + tla/disaster-recovery/Readme.md | 48 +++ tla/disaster-recovery/src/export.rs | 377 +++++++++++++++++ tla/disaster-recovery/src/main.rs | 32 +- 17 files changed, 2155 insertions(+), 1 deletion(-) create mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration.lean create mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean create mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean create mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean create mode 100644 lean/disaster-recovery-migration/ExportMain.lean create mode 100644 lean/disaster-recovery-migration/Main.lean create mode 100644 lean/disaster-recovery-migration/README.md create mode 100644 lean/disaster-recovery-migration/Tests.lean create mode 100755 lean/disaster-recovery-migration/compare.py create mode 100644 lean/disaster-recovery-migration/lake-manifest.json create mode 100644 lean/disaster-recovery-migration/lakefile.toml create mode 100644 lean/disaster-recovery-migration/lean-toolchain create mode 100644 tla/disaster-recovery/src/export.rs diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e648cab7db61..f01110a2aa77 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -114,6 +114,10 @@ and the proof implementation files marked as generated for review purposes. The standard `mk_all --check` command ensures that the audit root imports every library module, so newly added proofs cannot silently escape the checks. +The temporary migration-evidence job builds and audits the Lean mirror of the +legacy Rust/Stateright disaster recovery model, exercises both implementations, +and exhaustively compares their complete graphs for up to three nodes. + File: `lean.yml` 3rd party dependencies: None diff --git a/.github/workflows/lean.yml b/.github/workflows/lean.yml index d34e5678487f..ad2c53671133 100644 --- a/.github/workflows/lean.yml +++ b/.github/workflows/lean.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "lean/**" + - "tla/disaster-recovery/**" - ".github/workflows/lean.yml" concurrency: @@ -45,3 +46,55 @@ jobs: lake build --wfail lake lint lake exe canonical-checks + + migration-evidence: + name: Temporary migration evidence + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Lean and Rust + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y elan + elan toolchain install "$(cat lean/disaster-recovery-migration/lean-toolchain)" + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Build and check Rust model + working-directory: tla/disaster-recovery + shell: bash + run: | + set -euo pipefail + cargo check --locked + cargo build --locked + cargo run --quiet --locked -- --nodes 2 check + + - name: Restore Mathlib cache + working-directory: lean/disaster-recovery-migration + shell: bash + run: | + set -euo pipefail + lake exe cache get + + - name: Build and check migration model + working-directory: lean/disaster-recovery-migration + shell: bash + run: | + set -euo pipefail + lake exe mk_all --check --lib DisasterRecoveryMigration + lake build --wfail + lake lint + lake exe migration-semantic-checks + lake exe migration-model-checker --nodes 3 + + - name: Compare complete Rust and Lean graphs + working-directory: lean/disaster-recovery-migration + shell: bash + run: | + set -euo pipefail + python3 compare.py --nodes 1 2 3 diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration.lean new file mode 100644 index 000000000000..f17bd8ffc407 --- /dev/null +++ b/lean/disaster-recovery-migration/DisasterRecoveryMigration.lean @@ -0,0 +1,3 @@ +import DisasterRecoveryMigration.Legacy.Checker +import DisasterRecoveryMigration.Legacy.Model +import DisasterRecoveryMigration.Refinement diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean new file mode 100644 index 000000000000..659daef03546 --- /dev/null +++ b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean @@ -0,0 +1,152 @@ +import DisasterRecoveryMigration.Legacy.Model + +namespace DisasterRecoveryMigration.Legacy + +structure Edge where + src : Nat + action : Action + dst : Nat +deriving Repr, BEq + +structure Graph where + states : Array GlobalState + edges : Array Edge + parents : Array (Option (Prod Nat Action)) + +def enumerate (n : Nat) : IO Graph := do + let initial := initialState n + let mut states := #[initial] + let mut edges := #[] + let mut parents : Array (Option (Prod Nat Action)) := #[none] + let mut seen : Std.HashMap String Nat := {} + seen := seen.insert (stateKey initial) 0 + let mut cursor := 0 + while cursor < states.size do + let state := states[cursor]! + for action in actions state do + match nextState n state action with + | none => pure () + | some next => + let key := stateKey next + let (dst, discovered) := + match seen[key]? with + | some index => (index, false) + | none => (states.size, true) + if discovered then + seen := seen.insert key dst + states := states.push next + parents := parents.push (some (cursor, action)) + edges := edges.push { src := cursor, action, dst } + cursor := cursor + 1 + pure { states, edges, parents } + +def valuationBits (values : Array Bool) : String := + String.ofList (values.toList.map fun value => if value then '1' else '0') + +private structure ExportEdge where + src : Nat + action : String + dst : Nat + +private def exportEdgeLE (left right : ExportEdge) : Bool := + left.src < right.src || + (left.src == right.src && + (left.action < right.action || + (left.action == right.action && left.dst <= right.dst))) + +private def traceTo (graph : Graph) (target : Nat) : List Action := + let rec collect (index : Nat) (fuel : Nat) (suffix : List Action) : List Action := + match fuel with + | 0 => suffix + | fuel + 1 => + match graph.parents[index]? |>.bind id with + | none => suffix + | some (parent, action) => collect parent fuel (action :: suffix) + collect target graph.states.size [] + +private def printTrace (graph : Graph) (target : Nat) : IO Unit := do + let trace := traceTo graph target + if trace.isEmpty then + IO.eprintln " trace: " + else + for (action, step) in trace.zipIdx do + IO.eprintln s!" {step + 1}. {actionKey action}" + +private def eventuallyGood (graph : Graph) (property : Nat) : Array Bool := + Id.run do + let mut good := + graph.states.map fun state => (legacyValuations state.actors.size state)[property]! + let mut remaining := Array.replicate graph.states.size 0 + let mut predecessors : Array (List Nat) := Array.replicate graph.states.size [] + for edge in graph.edges do + remaining := remaining.modify edge.src (fun count => count + 1) + predecessors := predecessors.modify edge.dst (fun values => edge.src :: values) + let mut queue := #[] + for index in List.range good.size do + if good[index]! then queue := queue.push index + let mut cursor := 0 + while cursor < queue.size do + let resolved := queue[cursor]! + for predecessor in predecessors[resolved]! do + if !good[predecessor]! then + remaining := remaining.modify predecessor (fun count => count - 1) + if remaining[predecessor]! == 0 then + good := good.set! predecessor true + queue := queue.push predecessor + cursor := cursor + 1 + return good + +def checkGraph (n : Nat) (graph : Graph) : IO Bool := do + IO.eprintln s!"reachable states: {graph.states.size}, transitions: {graph.edges.size}" + let mut passed := true + for property in List.range legacyPropertyNames.size do + let name := legacyPropertyNames[property]! + let expectation := legacyExpectations[property]! + let values := graph.states.map fun state => (legacyValuations n state)[property]! + let eventual := if expectation == "eventually" then eventuallyGood graph property else #[] + let result := + if expectation == "always" then values.all id + else if expectation == "sometimes" then values.any id + else eventual[0]! + IO.eprintln s!"{if result then "PASS" else "FAIL"} [{expectation}] {name}" + if result && expectation == "sometimes" then + match (List.range values.size).find? (fun index => values[index]!) with + | none => pure () + | some index => + IO.eprintln " shortest example:" + printTrace graph index + else if !result then + passed := false + let witness := + if expectation == "always" then + (List.range values.size).find? fun index => !values[index]! + else if expectation == "sometimes" then + some 0 + else + (List.range values.size).find? fun index => + !eventual[index]! + match witness with + | none => IO.eprintln " no reachable example" + | some index => printTrace graph index + pure passed + +def exportGraph (n : Nat) (graph : Graph) : IO Unit := do + let canonical := (graph.states.toList.zipIdx.map fun (state, bfsId) => + (stateKey state, bfsId)).mergeSort (fun left right => left.1 <= right.1) + let mut ids := Array.replicate graph.states.size 0 + for ((_, bfsId), canonicalId) in canonical.zipIdx do + ids := ids.set! bfsId canonicalId + IO.println "format\tccf-legacy-dr-graph-v1" + IO.println s!"nodes\t{n}" + IO.println s!"init\t{ids[0]!}" + for ((key, bfsId), canonicalId) in canonical.zipIdx do + IO.println s!"state\t{canonicalId}\t{key}\t{valuationBits (legacyValuations n graph.states[bfsId]!)}" + let canonicalEdges := (graph.edges.toList.map fun edge => { + src := ids[edge.src]! + action := actionKey edge.action + dst := ids[edge.dst]! + }).mergeSort exportEdgeLE + for edge in canonicalEdges do + IO.println s!"edge\t{edge.src}\t{edge.action}\t{edge.dst}" + +end DisasterRecoveryMigration.Legacy diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean new file mode 100644 index 000000000000..91000ae2f1b5 --- /dev/null +++ b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean @@ -0,0 +1,392 @@ +import Std + +namespace DisasterRecoveryMigration.Legacy + +abbrev Id := Nat +abbrev Txid := Nat + +structure Gossip where + src : Id + txid : Txid +deriving Repr, BEq, Hashable + +structure Vote where + src : Id + recv : List Gossip +deriving Repr, BEq, Hashable + +inductive Msg where + | gossip (value : Gossip) + | vote (value : Vote) + | iAmOpen (src : Id) +deriving Repr, BEq, Hashable + +inductive Phase where + | vote + | openJoin + | open (timeout : Bool) + | join +deriving Repr, BEq, Hashable, Inhabited + +structure ActorState where + nextStep : Phase + gossips : List Gossip + votes : List Vote + submittedVote : Option (Prod Id Vote) + txid : Txid +deriving Repr, BEq, Hashable, Inhabited + +structure Envelope where + src : Id + dst : Id + msg : Msg +deriving Repr, BEq, Hashable + +structure GlobalState where + actors : Array ActorState + timers : Array Bool + network : List Envelope +deriving Repr, BEq, Hashable, Inhabited + +inductive Action where + | deliver (envelope : Envelope) + | timeout (id : Id) +deriving Repr, BEq, Hashable + +structure Output where + sent : List (Prod Id Msg) := [] + setTimer : Bool := false +deriving Repr, BEq + +private def comma (values : List String) : String := + String.intercalate "," values + +def gossipKey (gossip : Gossip) : String := + s!"g({gossip.src},{gossip.txid})" + +def voteKey (vote : Vote) : String := + s!"v({vote.src},[{comma (vote.recv.map gossipKey)}])" + +def msgKey : Msg -> String + | .gossip gossip => gossipKey gossip + | .vote vote => voteKey vote + | .iAmOpen src => s!"o({src})" + +def envelopeKey (envelope : Envelope) : String := + s!"e({envelope.src},{envelope.dst},{msgKey envelope.msg})" + +def phaseKey : Phase -> String + | .vote => "vote" + | .openJoin => "openjoin" + | .open false => "open0" + | .open true => "open1" + | .join => "join" + +def submittedKey : Option (Prod Id Vote) -> String + | none => "none" + | some (dst, vote) => s!"some({dst},{voteKey vote})" + +def actorKey (actor : ActorState) : String := + s!"s({phaseKey actor.nextStep},[{comma (actor.gossips.map gossipKey)}],[{comma (actor.votes.map voteKey)}],{submittedKey actor.submittedVote},{actor.txid})" + +private def networkRunsFrom (current : Envelope) (count : Nat) : + List Envelope -> List (Prod Envelope Nat) + | [] => [(current, count)] + | head :: tail => + if head == current then + networkRunsFrom current (count + 1) tail + else + (current, count) :: networkRunsFrom head 1 tail + +private def networkRuns : List Envelope -> List (Prod Envelope Nat) + | [] => [] + | head :: tail => networkRunsFrom head 1 tail + +def stateKey (state : GlobalState) : String := + let actors := String.intercalate ";" (state.actors.toList.map actorKey) + let timers := comma (((List.range state.timers.size).filter + (fun id => state.timers[id]!)).map toString) + let network := comma ((networkRuns state.network).map fun (env, count) => + s!"{envelopeKey env}#{count}") + s!"S([{actors}],[{timers}],[{network}])" + +def actionKey : Action -> String + | .deliver env => s!"deliver({env.src},{env.dst},{msgKey env.msg})" + | .timeout id => s!"timeout({id},election)" + +private def insertSorted (before : a -> a -> Bool) (value : a) : List a -> List a + | [] => [value] + | head :: tail => + if before value head then + value :: head :: tail + else + head :: insertSorted before value tail + +private def insertUniqueSorted [BEq a] (before : a -> a -> Bool) (value : a) (values : List a) : + List a := + if values.contains value then values else insertSorted before value values + +private def removeOne [BEq a] (value : a) : List a -> List a + | [] => [] + | head :: tail => if head == value then tail else head :: removeOne value tail + +private def gossipGreater (left right : Gossip) : Bool := + right.txid < left.txid || (right.txid == left.txid && right.src < left.src) + +private def gossipBefore (left right : Gossip) : Bool := + left.src < right.src || (left.src == right.src && left.txid < right.txid) + +private def gossipListBefore : List Gossip -> List Gossip -> Bool + | [], [] => false + | [], _ :: _ => true + | _ :: _, [] => false + | left :: leftTail, right :: rightTail => + if left == right then gossipListBefore leftTail rightTail + else gossipBefore left right + +private def voteBefore (left right : Vote) : Bool := + left.src < right.src || (left.src == right.src && gossipListBefore left.recv right.recv) + +private def msgBefore : Msg -> Msg -> Bool + | .gossip left, .gossip right => gossipBefore left right + | .gossip _, _ => true + | .vote _, .gossip _ => false + | .vote left, .vote right => voteBefore left right + | .vote _, .iAmOpen _ => true + | .iAmOpen _, .gossip _ => false + | .iAmOpen _, .vote _ => false + | .iAmOpen left, .iAmOpen right => left < right + +private def envelopeBefore (left right : Envelope) : Bool := + left.src < right.src || + (left.src == right.src && + (left.dst < right.dst || (left.dst == right.dst && msgBefore left.msg right.msg))) + +private def maximumGossip : List Gossip -> Option Gossip + | [] => none + | head :: tail => + some (tail.foldl (fun current candidate => + if gossipGreater candidate current then candidate else current) head) + +private def voteForMax (gossips : List Gossip) (id : Id) : Option (Prod Id Vote) := do + let maximum <- maximumGossip gossips + pure (maximum.src, { src := id, recv := gossips }) + +private def otherPeers (n id : Nat) : List Id := + (List.range n).filter (fun peer => peer != id) + +private def advanceStep (n id : Nat) (timeout : Bool) (state : ActorState) : + Prod ActorState (Prod Output Bool) := + match state.nextStep with + | .vote => + if state.gossips.length == n || timeout then + match voteForMax state.gossips id with + | none => (state, {}, false) + | some (dst, vote) => + let next := { + state with + nextStep := .openJoin + submittedVote := some (dst, vote) + votes := if dst == id then insertUniqueSorted voteBefore vote state.votes else state.votes + } + let sent := if dst == id then [] else [(dst, Msg.vote vote)] + (next, { sent }, true) + else + (state, {}, false) + | .openJoin => + if state.votes.length >= (n + 1) / 2 || timeout then + let sent := (otherPeers n id).map (fun peer => (peer, Msg.iAmOpen id)) + ({ state with nextStep := .open timeout }, { sent }, true) + else + (state, {}, false) + | _ => (state, {}, false) + +def advanceSeveral (n id : Nat) (timeout : Bool) (state : ActorState) : + Prod ActorState Output := + let (state1, output1, advanced1) := advanceStep n id timeout state + if advanced1 then + let (state2, output2, _) := advanceStep n id timeout state1 + (state2, { sent := output1.sent ++ output2.sent }) + else + (state, {}) + +def onMessage (n id : Nat) (state : ActorState) (msg : Msg) : + Option (Prod ActorState Output) := + let received := + match msg with + | .gossip gossip => + if !state.gossips.contains gossip && state.submittedVote.isNone then + { state with gossips := insertUniqueSorted gossipBefore gossip state.gossips } + else + state + | .vote vote => + { state with votes := insertUniqueSorted voteBefore vote state.votes } + | .iAmOpen _ => + match state.nextStep with + | .open _ => state + | _ => { state with nextStep := .join } + let (next, output) := advanceSeveral n id false received + some (next, output) + +def onTimeout (n id : Nat) (state : ActorState) : Option (Prod ActorState Output) := + match state.nextStep with + | .vote => + if state.gossips.isEmpty then none + else + let (next, output) := advanceSeveral n id true state + some (next, { output with setTimer := true }) + | .openJoin => + if state.votes.isEmpty then none + else some (advanceSeveral n id true state) + | _ => none + +private def applyOutput (src : Id) (output : Output) (state : GlobalState) : GlobalState := + let network := output.sent.foldl + (fun current (dst, msg) => insertSorted envelopeBefore { src, dst, msg } current) + state.network + let timers := if output.setTimer then state.timers.set! src true else state.timers + { state with network, timers } + +private def startActor (n id : Nat) : Prod ActorState Output := + let gossip := { src := id, txid := id } + let initial : ActorState := { + nextStep := .vote + gossips := [gossip] + votes := [] + submittedVote := none + txid := id + } + let output : Output := { + sent := (otherPeers n id).map (fun peer => (peer, Msg.gossip gossip)) + setTimer := true + } + let (state, advanced) := advanceSeveral n id false initial + (state, { sent := output.sent ++ advanced.sent, setTimer := true }) + +def initialState (n : Nat) : GlobalState := + (List.range n).foldl (fun global id => + let (actor, output) := startActor n id + let withActor := { + global with + actors := global.actors.push actor + timers := global.timers.push false + } + applyOutput id output withActor) + { actors := #[], timers := #[], network := [] } + +private def distinctNetworkFrom (previous : Envelope) : List Envelope -> List Envelope + | [] => [] + | head :: tail => + if head == previous then + distinctNetworkFrom previous tail + else + head :: distinctNetworkFrom head tail + +private def distinctNetwork : List Envelope -> List Envelope + | [] => [] + | head :: tail => head :: distinctNetworkFrom head tail + +def actions (state : GlobalState) : List Action := + (distinctNetwork state.network).map Action.deliver ++ + ((List.range state.timers.size).filter + (fun id => state.timers[id]!)).map Action.timeout + +def nextState (n : Nat) (state : GlobalState) : Action -> Option GlobalState + | .deliver envelope => do + let actor <- state.actors[envelope.dst]? + let (nextActor, output) <- onMessage n envelope.dst actor envelope.msg + let delivered := { + state with + actors := state.actors.set! envelope.dst nextActor + network := removeOne envelope state.network + } + pure (applyOutput envelope.dst output delivered) + | .timeout id => do + guard (state.timers[id]?.getD false) + let actor <- state.actors[id]? + let (nextActor, output) <- onTimeout n id actor + let expired := { + state with + actors := state.actors.set! id nextActor + timers := state.timers.set! id false + } + pure (applyOutput id output expired) + +def reachedOpen (state : GlobalState) : Bool := + state.actors.any fun actor => + match actor.nextStep with + | .open _ => true + | _ => false + +def reachedOpenTimeout (state : GlobalState) (expected : Bool) : Bool := + state.actors.any fun actor => actor.nextStep == .open expected + +def unanimousVotes (n : Nat) (state : GlobalState) : Bool := + state.actors.all fun actor => + match actor.submittedVote with + | none => false + | some (_, vote) => + (List.range n).all fun peer => vote.recv.any (fun gossip => gossip.src == peer) + +def majorityHaveSameMaximum (state : GlobalState) : Bool := + let chosen := state.actors.toList.filterMap fun actor => do + let (_, vote) <- actor.submittedVote + let maximum <- maximumGossip vote.recv + pure maximum.src + let chosen := chosen.foldl (fun values id => + insertSorted (fun left right => left < right) id values) [] + let majorityIndex := state.actors.size / 2 + match chosen[majorityIndex]? with + | none => false + | some majority => (chosen.take majorityIndex).all (fun chosen => chosen == majority) + +private def implies (left right : Bool) : Bool := + !left || right + +def legacyValuations (n : Nat) (state : GlobalState) : Array Bool := + let openCount := state.actors.countP fun actor => + match actor.nextStep with + | .open _ => true + | _ => false + let allOpenJoin := state.actors.all (fun actor => actor.nextStep == .openJoin) + let allVotesDelivered := !state.network.any fun envelope => + match envelope.msg with + | .vote _ => true + | _ => false + let majorityIndex := state.actors.size / 2 + let commitTxid := (state.actors[majorityIndex]!).txid + let persisted := state.actors.all fun actor => + match actor.nextStep with + | .open _ => actor.txid >= commitTxid + | _ => true + #[ + implies (unanimousVotes n state) (reachedOpenTimeout state false), + reachedOpen state, + implies (majorityHaveSameMaximum state) (reachedOpenTimeout state false), + implies (!reachedOpenTimeout state true) (openCount <= 1), + !(allOpenJoin && allVotesDelivered), + implies (!reachedOpenTimeout state true) persisted, + implies (state.actors.size > 1) (reachedOpen state), + reachedOpenTimeout state true, + majorityHaveSameMaximum state && reachedOpenTimeout state false + ] + +def legacyPropertyNames : Array String := #[ + "Unanimous votes => no chance of a fork", + "Open", + "Majority votes => no fork", + "No open with timeout, no fork", + "Deadlock", + "Persist committed txs", + "Open is possible", + "Unsafe open with timeout", + "Majority vote still opens without timeout" +] + +def legacyExpectations : Array String := #[ + "eventually", "eventually", "eventually", + "always", "always", "always", + "sometimes", "sometimes", "sometimes" +] + +end DisasterRecoveryMigration.Legacy diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean new file mode 100644 index 000000000000..6a3a74ccb613 --- /dev/null +++ b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean @@ -0,0 +1,335 @@ +import DisasterRecoveryMigration.Legacy.Model +import DisasterRecovery.Protocol.Model +import Mathlib.Logic.Relation + +namespace DisasterRecoveryMigration.Refinement + +open DisasterRecovery.Protocol.Model + +def projectPhase (state : NodeState) : DisasterRecoveryMigration.Legacy.Phase := + match state.phase with + | .gossiping => .vote + | .voting => .openJoin + | .opening | .open => + match state.openKind with + | some .failover => .open true + | _ => .open false + | .joining => .join + +inductive LegacyAtomic : + DisasterRecoveryMigration.Legacy.Phase -> DisasterRecoveryMigration.Legacy.Phase -> Prop where + | gossipToVoting : LegacyAtomic .vote .openJoin + | quorumOpen : LegacyAtomic .openJoin (.open false) + | failoverOpen : LegacyAtomic .openJoin (.open true) + | gossipToJoin : LegacyAtomic .vote .join + | votingToJoin : LegacyAtomic .openJoin .join + +abbrev LegacyWeakStep := + Relation.ReflTransGen LegacyAtomic + +def embeddedTxID + (config : Config) + (source : Location) + (txid : TxID) : Prop := + txid.view = 0 /\ config.expectedLocations[txid.seqno]? = some source + +structure LegacyDataAssumptions + (config : Config) + (event : Event) + (after : NodeState) : Prop where + /-- Recorded for a future data refinement; phase simulation does not assume it. -/ + oddNodeCount : + exists half, config.expectedLocations.length = 2 * half + 1 + acceptedExpectedInput : + match event with + | .receiveGossip source txid validation => + validation = .accepted /\ + expectedSource config source = true /\ + embeddedTxID config source txid + | .receiveVote source validation => + validation = .accepted /\ expectedSource config source = true + | .receiveIAmOpen source validation => + validation = .accepted /\ expectedSource config source = true + | .timeout | .retry => True + quorumOnly : + after.openKind != some .failover + +structure CompatibilityStep + (config : Config) + (before : NodeState) + (event : Event) + (after : NodeState) : Prop where + canonical : + after = (step config before event).state + +private theorem advance_simulates + (config : Config) + (state : NodeState) + (timeout : Bool) + (output : StepOutput) + (advanced : advance config state timeout = some output) : + LegacyWeakStep (projectPhase state) (projectPhase output.state) := by + cases timeout <;> cases phase : state.phase <;> + simp [advance, phase] at advanced <;> + repeat' split at advanced <;> + simp_all [projectPhase, advanceTimeoutLane, advanceTimeoutState] + all_goals subst output + all_goals simp_all [projectPhase, advanceTimeoutLane] + all_goals + first + | (split <;> simp_all) + | skip + all_goals + first + | exact .refl + | exact .single .gossipToVoting + | exact .single .quorumOpen + | exact .single .failoverOpen + +private theorem receive_gossip_simulates + (config : Config) + (before : NodeState) + (source : Location) + (txid : TxID) + (validation : Validation) : + LegacyWeakStep + (projectPhase before) + (projectPhase + (step config before (.receiveGossip source txid validation)).state) := by + cases validation with + | rejected => + simp [step, rejected] + exact .refl + | accepted => + by_cases frozen : before.chosen != none + case pos => + simp [step, frozen, rejected] + exact .refl + case neg => + let received := { + before with gossips := insertGossip source txid before.gossips } + have same : projectPhase received = projectPhase before := by + simp [received, projectPhase] + cases advanced : advance config received false with + | none => + simp [step, frozen, received, advanced, rejected] + exact .refl + | some output => + simp [step, frozen, received, advanced] + have simulation := + advance_simulates config received false output advanced + rw [same] at simulation + exact simulation + +private theorem receive_vote_simulates + (config : Config) + (before : NodeState) + (source : Location) + (validation : Validation) : + LegacyWeakStep + (projectPhase before) + (projectPhase + (step config before (.receiveVote source validation)).state) := by + cases validation with + | rejected => + simp [step, rejected] + exact .refl + | accepted => + let received := { before with votes := insertVote source before.votes } + have same : projectPhase received = projectPhase before := by + simp [received, projectPhase] + cases advanced : advance config received false with + | none => + simp [step, received, advanced, rejected] + exact .refl + | some output => + simp [step, received, advanced] + have simulation := + advance_simulates config received false output advanced + rw [same] at simulation + exact simulation + +private theorem receive_iamopen_simulates + (config : Config) + (before : NodeState) + (source : Location) + (validation : Validation) : + LegacyWeakStep + (projectPhase before) + (projectPhase + (step config before (.receiveIAmOpen source validation)).state) := by + cases validation with + | rejected => + simp [step, rejected] + exact .refl + | accepted => + cases phase : before.phase <;> + simp [step, phase, advance, rejected, projectPhase, + advanceTimeoutLane] + all_goals + first + | exact .single .gossipToJoin + | exact .single .votingToJoin + | exact .refl + +theorem canonical_step_simulates + (config : Config) + (before : NodeState) + (event : Event) : + LegacyWeakStep + (projectPhase before) + (projectPhase (step config before event).state) := by + cases event with + | receiveGossip source txid validation => + exact receive_gossip_simulates config before source txid validation + | receiveVote source validation => + exact receive_vote_simulates config before source validation + | receiveIAmOpen source validation => + exact receive_iamopen_simulates config before source validation + | timeout => + cases advanced : advance config before true with + | none => + simp [step, advanced, rejected] + exact .refl + | some output => + simp [step, advanced] + exact advance_simulates config before true output advanced + | retry => + exact .refl + +theorem compatibility_step_simulates + {config : Config} + {before after : NodeState} + {event : Event} + (compatible : CompatibilityStep config before event after) : + LegacyWeakStep (projectPhase before) (projectPhase after) := by + rw [compatible.canonical] + exact canonical_step_simulates config before event + +theorem retryCompatibility + (config : Config) + (state : NodeState) : + CompatibilityStep config state .retry state := { + canonical := rfl +} + +theorem voteQuorumCompatibility + (config : Config) + (before : NodeState) + (source : Location) : + CompatibilityStep config before + (.receiveVote source .accepted) + (step config before (.receiveVote source .accepted)).state := { + canonical := rfl +} + +theorem quorum_phase_step_is_weak + (before after : NodeState) + (beforePhase : before.phase = .voting) + (afterPhase : after.phase = .opening) + (kind : after.openKind = some .quorum) : + LegacyWeakStep (projectPhase before) (projectPhase after) := by + simp [projectPhase, beforePhase, afterPhase, kind] + exact .single .quorumOpen + +theorem opening_to_open_is_stuttering + (before after : NodeState) + (beforePhase : before.phase = .opening) + (afterPhase : after.phase = .open) + (kind : after.openKind = before.openKind) : + LegacyWeakStep (projectPhase before) (projectPhase after) := by + simp [projectPhase, beforePhase, afterPhase, kind] + exact .refl + +inductive CompatibilityTrace + (config : Config) : + NodeState -> + List Event -> + NodeState -> + Prop where + | nil (state) : CompatibilityTrace config state [] state + | cons + (first middle last event rest) + (head : CompatibilityStep config first event middle) + (tail : CompatibilityTrace config middle rest last) : + CompatibilityTrace config first (event :: rest) last + +theorem compatibility_trace_simulates + {config : Config} + {first last : NodeState} + {events : List Event} + (compatible : CompatibilityTrace config first events last) : + LegacyWeakStep (projectPhase first) (projectPhase last) := by + induction compatible with + | nil state => exact .refl + | cons first middle last event rest head tail induction => + exact Relation.ReflTransGen.trans + (compatibility_step_simulates head) induction + +theorem initial_phase_correspondence : + projectPhase (initialNode "node0") = DisasterRecoveryMigration.Legacy.Phase.vote := by + rfl + +theorem three_node_initial_correspondence : + let config : Config := { + instanceId := "compat" + expectedLocations := ["0", "1", "2"] + } + ((initialSystem config).nodes.map + (fun entry => projectPhase entry.2) == + (DisasterRecoveryMigration.Legacy.initialState 3).actors.toList.map + (fun actor => actor.nextStep)) = true := by + rfl + +theorem odd_quorum_matches_legacy + (nodes half : Nat) + (odd : nodes = 2 * half + 1) : + nodes / 2 + 1 = (nodes + 1) / 2 := by + subst nodes + simp [Nat.add_div] + +theorem even_quorum_exceeds_legacy_by_one + (nodes half : Nat) + (even : nodes = 2 * half) : + nodes / 2 + 1 = (nodes + 1) / 2 + 1 := by + subst nodes + simp [Nat.add_div] + +def canonicalReachedOpen (state : NodeState) : Prop := + state.phase = .opening \/ state.phase = .open + +def projectedReachedOpen (state : NodeState) : Prop := + match projectPhase state with + | .open _ => True + | _ => False + +theorem reached_open_is_preserved + (state : NodeState) : + canonicalReachedOpen state <-> projectedReachedOpen state := by + cases phase : state.phase <;> + cases kind : state.openKind <;> + simp [canonicalReachedOpen, projectedReachedOpen, projectPhase, phase, kind] + all_goals + rename_i value + cases value <;> + simp + +theorem quorum_kind_projects_to_non_timeout_open + (state : NodeState) + (phase : state.phase = .opening \/ state.phase = .open) + (kind : state.openKind = some .quorum) : + projectPhase state = .open false := by + cases phase with + | inl opening => + cases state + simp_all [projectPhase] + | inr opened => + cases state + simp_all [projectPhase] + +theorem single_node_full_initial_models_differ : + projectPhase (initialNode "0") != + (DisasterRecoveryMigration.Legacy.initialState 1).actors[0]!.nextStep := by + decide + +end DisasterRecoveryMigration.Refinement \ No newline at end of file diff --git a/lean/disaster-recovery-migration/ExportMain.lean b/lean/disaster-recovery-migration/ExportMain.lean new file mode 100644 index 000000000000..f7500f6a967c --- /dev/null +++ b/lean/disaster-recovery-migration/ExportMain.lean @@ -0,0 +1,24 @@ +import DisasterRecoveryMigration.Legacy.Checker + +open DisasterRecoveryMigration.Legacy + +private def usage : String := + "usage: migration-exporter [--nodes N]" + +private def parseNodes : List String -> Except String Nat + | [] => pure 3 + | ["--nodes", value] => + match value.toNat? with + | some n => if n > 0 then pure n else throw "--nodes must be positive" + | none => throw s!"invalid node count: {value}" + | _ => throw usage + +def main (args : List String) : IO UInt32 := do + match parseNodes args with + | .error message => + IO.eprintln message + pure 2 + | .ok n => + let graph <- enumerate n + exportGraph n graph + pure 0 diff --git a/lean/disaster-recovery-migration/Main.lean b/lean/disaster-recovery-migration/Main.lean new file mode 100644 index 000000000000..c943598da3bc --- /dev/null +++ b/lean/disaster-recovery-migration/Main.lean @@ -0,0 +1,23 @@ +import DisasterRecoveryMigration.Legacy.Checker + +open DisasterRecoveryMigration.Legacy + +private def usage : String := + "usage: migration-model-checker [--nodes N]" + +private def parseNodes : List String -> Except String Nat + | [] => pure 3 + | ["--nodes", value] => + match value.toNat? with + | some n => if n > 0 then pure n else throw "--nodes must be positive" + | none => throw s!"invalid node count: {value}" + | _ => throw usage + +def main (args : List String) : IO UInt32 := do + match parseNodes args with + | .error message => + IO.eprintln message + pure 2 + | .ok n => + let graph <- enumerate n + if <- checkGraph n graph then pure 0 else pure 1 diff --git a/lean/disaster-recovery-migration/README.md b/lean/disaster-recovery-migration/README.md new file mode 100644 index 000000000000..3ea813d5400b --- /dev/null +++ b/lean/disaster-recovery-migration/README.md @@ -0,0 +1,113 @@ +# Temporary disaster recovery migration evidence + +This package is the temporary PR 2 evidence layer for migrating the legacy +Rust/Stateright disaster recovery model to Lean. It depends locally on the +canonical package in `../disaster-recovery`; it does not modify or duplicate +that package. This directory and the shared Lean workflow's migration-evidence +job are intended to be deleted by PR 3 once the evidence has served its +purpose. + +## Scope + +There are two distinct and deliberately weaker claims: + +1. The executable model in `DisasterRecoveryMigration.Legacy` is an exact + Lean mirror of the Rust/Stateright model in `tla/disaster-recovery`. + `compare.py` establishes exhaustive bounded equivalence for one, two, and + three nodes. +2. `DisasterRecoveryMigration.Refinement` relates the canonical C++-aligned + Lean model to the legacy Lean mirror only at the protocol-phase level. + +The bounded comparison is not a theorem about arbitrary node counts or a +formal semantics for Rust or Stateright. The phase refinement is not a full +bisimulation, data refinement, or proof that the canonical model is identical +to the Rust model. + +## Exact bounded equivalence + +Both exporters emit the stable `ccf-legacy-dr-graph-v1` format. State IDs are +assigned after sorting normalized state keys, independently of traversal +order. For each requested node count, `compare.py` checks: + +- the normalized initial state; +- every normalized reachable state in both directions; +- every labeled edge, including source and destination, in both directions; +- all nine registered predicate valuations for every reachable state; and +- the expected complete state and edge counts below. + +| Nodes | Reachable states | Labeled edges | Predicate values per state | +| ----: | ---------------: | ------------: | -------------------------: | +| 1 | 1 | 0 | 9 | +| 2 | 54 | 95 | 9 | +| 3 | 105,558 | 552,282 | 9 | + +The comparator fails on a difference from either exporter and reports a +shortest path to a representative state or edge mismatch. + +The mirror intentionally retains the legacy semantics, including message +multiplicity, unordered delivery, timer behavior, no-op suppression, immediate +multi-phase advancement, and the existing predicate definitions and names. +Differences in the canonical model are not backported into this oracle. + +## Canonical phase refinement and limitations + +`DisasterRecoveryMigration.Refinement` imports the canonical +`DisasterRecovery.Protocol.Model` through the local Lake dependency and +projects canonical phases as follows: + +- Gossiping maps to legacy Vote. +- Voting maps to legacy OpenJoin. +- canonical Opening and Open collapse to legacy Open, retaining quorum versus + failover as the legacy timeout flag. +- Joining maps to legacy Join. + +`canonical_step_simulates` proves that each canonical local step projects to a +reflexive-transitive legacy phase step. The file also proves finite compatible +trace simulation, collapsed-Open preservation, quorum-kind projection, and +Opening-to-Open stuttering. + +This phase-only result does not relate gossip sets, votes, timeout-lane state, +network state, transaction persistence, or all nine legacy predicates. It +does not establish a global scheduler correspondence or preserve the legacy +liveness expectations. + +Two intentional model differences are explicit: + +- The canonical quorum is the strict majority `n / 2 + 1`; the legacy quorum + is `(n + 1) / 2`. They agree for odd node counts, while for even node counts + the canonical threshold is one larger. +- With one node, the legacy full initial state opens immediately without a + timeout. The canonical initial node remains in Gossiping, whose projected + phase is Vote. `single_node_full_initial_models_differ` proves this mismatch. + +## Files + +| File | Purpose | +| ----------------------------------------------- | --------------------------------------------- | +| `DisasterRecoveryMigration/Legacy/Model.lean` | Exact executable legacy semantics | +| `DisasterRecoveryMigration/Legacy/Checker.lean` | BFS model checker and canonical graph encoder | +| `Main.lean` | Legacy model-checker CLI | +| `ExportMain.lean` | Separate Lean graph-exporter CLI | +| `Tests.lean` | Focused legacy semantic checks | +| `DisasterRecoveryMigration/Refinement.lean` | Canonical-to-legacy phase refinement | +| `compare.py` | Bidirectional exhaustive Rust/Lean comparison | + +## Validation + +Run from this directory: + +```console +lake exe cache get +lake exe mk_all --check --lib DisasterRecoveryMigration +lake build --wfail +lake lint +lake exe migration-semantic-checks +lake exe migration-model-checker --nodes 3 +python3 compare.py --nodes 1 2 3 +``` + +The canonical package's own axiom-audit configuration remains authoritative +for all canonical declarations and is run by the canonical job in the shared +Lean workflow. The migration Lake package pins Lean 4.33.1, transitively +resolves Mathlib v4.33.1, treats warnings as errors, verifies complete library +coverage with `mk_all --check`, and audits transitive axioms with `lake lint`. diff --git a/lean/disaster-recovery-migration/Tests.lean b/lean/disaster-recovery-migration/Tests.lean new file mode 100644 index 000000000000..1555b42e0972 --- /dev/null +++ b/lean/disaster-recovery-migration/Tests.lean @@ -0,0 +1,72 @@ +import DisasterRecoveryMigration.Legacy.Model + +open DisasterRecoveryMigration.Legacy + +private def expect (condition : Bool) (message : String) : IO Unit := + unless condition do throw (IO.userError message) + +private def deliverByKey (n : Nat) (state : GlobalState) (key : String) : Option GlobalState := do + let action <- (actions state).find? (fun action => actionKey action == key) + nextState n state action + +def main : IO UInt32 := do + let single := initialState 1 + expect (single.actors[0]!.nextStep == .open false) + "single node did not open immediately without timeout" + + let initial3 := initialState 3 + let timed <- match nextState 3 initial3 (.timeout 0) with + | some state => pure state + | none => throw (IO.userError "node 0 timeout was suppressed") + expect (timed.actors[0]!.nextStep == .open true) + "timeout did not drive vote and open-join closure to timeout-open" + + let opened := timed.actors[0]! + let lateGossip : Gossip := { src := 2, txid := 2 } + let frozen <- match onMessage 3 0 opened (.gossip lateGossip) with + | some result => pure result + | none => throw (IO.userError "message callback was unexpectedly suppressed") + expect (frozen.1.gossips == opened.gossips) + "gossip collection changed after the vote was submitted" + + let joinActor : ActorState := { + nextStep := .openJoin + gossips := [{ src := 1, txid := 1 }] + votes := [] + submittedVote := none + txid := 1 + } + let joined <- match onMessage 3 1 joinActor (.iAmOpen 0) with + | some result => pure result + | none => throw (IO.userError "IAmOpen was suppressed") + expect (joined.1.nextStep == .join) "IAmOpen did not cause Join" + + let firstOrder <- match deliverByKey 3 initial3 "deliver(1,0,g(1,1))" with + | some state => pure state + | none => throw (IO.userError "first unordered delivery failed") + let firstOrder <- match deliverByKey 3 firstOrder "deliver(2,0,g(2,2))" with + | some state => pure state + | none => throw (IO.userError "second unordered delivery failed") + let secondOrder <- match deliverByKey 3 initial3 "deliver(2,0,g(2,2))" with + | some state => pure state + | none => throw (IO.userError "reverse first unordered delivery failed") + let secondOrder <- match deliverByKey 3 secondOrder "deliver(1,0,g(1,1))" with + | some state => pure state + | none => throw (IO.userError "reverse second unordered delivery failed") + expect (firstOrder == secondOrder) "unordered deliveries produced different states" + + let duplicate : Envelope := { src := 1, dst := 0, msg := .gossip { src := 1, txid := 1 } } + let duplicated := { initial3 with network := duplicate :: duplicate :: initial3.network } + let once <- match nextState 3 duplicated (.deliver duplicate) with + | some state => pure state + | none => throw (IO.userError "first duplicate delivery was suppressed") + expect (once.network.count duplicate + 1 == duplicated.network.count duplicate) + "delivery did not remove exactly one duplicate" + let twice <- match nextState 3 once (.deliver duplicate) with + | some state => pure state + | none => throw (IO.userError "second duplicate delivery was suppressed") + expect (twice.network.count duplicate + 1 == once.network.count duplicate) + "second delivery did not remove exactly one duplicate" + + IO.println "all Lean semantic checks passed" + pure 0 diff --git a/lean/disaster-recovery-migration/compare.py b/lean/disaster-recovery-migration/compare.py new file mode 100755 index 000000000000..005d09c1df5a --- /dev/null +++ b/lean/disaster-recovery-migration/compare.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +import argparse +import filecmp +import subprocess +import sys +import tempfile +from collections import defaultdict, deque +from dataclasses import dataclass +from pathlib import Path + +FORMAT = "ccf-legacy-dr-graph-v1" +PROPERTY_NAMES = ( + "Unanimous votes => no chance of a fork", + "Open", + "Majority votes => no fork", + "No open with timeout, no fork", + "Deadlock", + "Persist committed txs", + "Open is possible", + "Unsafe open with timeout", + "Majority vote still opens without timeout", +) +EXPECTED_COUNTS = { + 1: (1, 0), + 2: (54, 95), + 3: (105558, 552282), +} + + +@dataclass(frozen=True) +class Summary: + initial_key: str + states: int + edges: int + + +@dataclass +class Graph: + initial: str + valuations: dict[str, str] + edges: set[tuple[str, str, str]] + + +def run(command: list[str], cwd: Path, output: Path | None = None) -> None: + print(f"+ (cd {cwd} && {' '.join(command)})", flush=True) + if output is None: + result = subprocess.run( + command, cwd=cwd, text=True, capture_output=True, check=False + ) + else: + with output.open("w", encoding="ascii", newline="") as stream: + result = subprocess.run( + command, + cwd=cwd, + text=True, + stdout=stream, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + if result.stderr: + print(result.stderr, file=sys.stderr, end="") + raise RuntimeError(f"command exited with status {result.returncode}") + + +def validate(path: Path, expected_nodes: int) -> Summary: + ids_to_keys: list[str] = [] + initial_id: int | None = None + edge_count = 0 + previous_edge: tuple[int, str, int] | None = None + section = "header" + + with path.open(encoding="ascii") as stream: + for line_number, raw_line in enumerate(stream, 1): + fields = raw_line.rstrip("\n").split("\t") + if fields == ["format", FORMAT] and line_number == 1: + continue + if fields == ["nodes", str(expected_nodes)] and line_number == 2: + continue + if len(fields) == 2 and fields[0] == "init" and line_number == 3: + initial_id = int(fields[1]) + section = "states" + continue + if len(fields) == 4 and fields[0] == "state" and section == "states": + state_id = int(fields[1]) + if state_id != len(ids_to_keys): + raise ValueError( + f"{path}:{line_number}: expected dense state id " + f"{len(ids_to_keys)}, found {state_id}" + ) + if ids_to_keys and fields[2] <= ids_to_keys[-1]: + raise ValueError( + f"{path}:{line_number}: state keys are unsorted or duplicated" + ) + bits = fields[3] + if len(bits) != len(PROPERTY_NAMES) or set(bits) - {"0", "1"}: + raise ValueError( + f"{path}:{line_number}: invalid property bitstring" + ) + ids_to_keys.append(fields[2]) + continue + if len(fields) == 4 and fields[0] == "edge": + section = "edges" + edge = (int(fields[1]), fields[2], int(fields[3])) + if edge[0] >= len(ids_to_keys) or edge[2] >= len(ids_to_keys): + raise ValueError( + f"{path}:{line_number}: edge references unknown state" + ) + if previous_edge is not None and edge <= previous_edge: + raise ValueError( + f"{path}:{line_number}: edges are unsorted or duplicated" + ) + previous_edge = edge + edge_count += 1 + continue + raise ValueError( + f"{path}:{line_number}: invalid record: {raw_line.rstrip()}" + ) + + if initial_id is None or initial_id >= len(ids_to_keys): + raise ValueError(f"{path}: invalid or missing initial state") + return Summary(ids_to_keys[initial_id], len(ids_to_keys), edge_count) + + +def load(path: Path) -> Graph: + ids_to_keys: list[str] = [] + valuations: dict[str, str] = {} + raw_edges: list[tuple[int, str, int]] = [] + initial_id = -1 + with path.open(encoding="ascii") as stream: + for raw_line in stream: + fields = raw_line.rstrip("\n").split("\t") + if fields[0] == "init": + initial_id = int(fields[1]) + elif fields[0] == "state": + state_id = int(fields[1]) + key = fields[2] + if state_id != len(ids_to_keys): + raise ValueError(f"{path}: non-dense state IDs") + ids_to_keys.append(key) + valuations[key] = fields[3] + elif fields[0] == "edge": + raw_edges.append((int(fields[1]), fields[2], int(fields[3]))) + edges = { + (ids_to_keys[src], action, ids_to_keys[dst]) for src, action, dst in raw_edges + } + return Graph(ids_to_keys[initial_id], valuations, edges) + + +def shortest_paths(graph: Graph) -> tuple[dict[str, int], dict[str, tuple[str, str]]]: + adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list) + for src, action, dst in graph.edges: + adjacency[src].append((action, dst)) + for outgoing in adjacency.values(): + outgoing.sort() + + distance = {graph.initial: 0} + parent: dict[str, tuple[str, str]] = {} + pending = deque([graph.initial]) + while pending: + src = pending.popleft() + for action, dst in adjacency[src]: + if dst not in distance: + distance[dst] = distance[src] + 1 + parent[dst] = (src, action) + pending.append(dst) + return distance, parent + + +def describe_path( + graph: Graph, target: str, cached: tuple[dict[str, int], dict[str, tuple[str, str]]] +) -> str: + distance, parent = cached + if target not in distance: + return f"unreachable target key {target}" + actions: list[str] = [] + cursor = target + while cursor != graph.initial: + cursor, action = parent[cursor] + actions.append(action) + actions.reverse() + rendered = "\n".join( + f" {index}. {action}" for index, action in enumerate(actions, 1) + ) + return f"target: {target}\n{rendered or ' '}" + + +def mismatch(rust: Graph, lean: Graph) -> str: + if rust.initial != lean.initial: + return f"initial state mismatch\nRust: {rust.initial}\nLean: {lean.initial}" + + rust_paths = lean_paths = None + rust_states = set(rust.valuations) + lean_states = set(lean.valuations) + if rust_states != lean_states: + rust_only = rust_states - lean_states + lean_only = lean_states - rust_states + candidates: list[tuple[int, str, str, Graph]] = [] + if rust_only: + rust_paths = shortest_paths(rust) + state = min( + rust_only, key=lambda key: (rust_paths[0].get(key, sys.maxsize), key) + ) + candidates.append( + (rust_paths[0].get(state, sys.maxsize), "Rust-only", state, rust) + ) + if lean_only: + lean_paths = shortest_paths(lean) + state = min( + lean_only, key=lambda key: (lean_paths[0].get(key, sys.maxsize), key) + ) + candidates.append( + (lean_paths[0].get(state, sys.maxsize), "Lean-only", state, lean) + ) + _, side, state, graph = min(candidates) + paths = rust_paths if graph is rust else lean_paths + return ( + f"reachable state mismatch ({len(rust_only)} Rust-only, " + f"{len(lean_only)} Lean-only); shortest is {side}\n" + f"{describe_path(graph, state, paths)}" + ) + + rust_only_edges = rust.edges - lean.edges + lean_only_edges = lean.edges - rust.edges + if rust_only_edges or lean_only_edges: + candidates = [] + if rust_only_edges: + rust_paths = shortest_paths(rust) + edge = min( + rust_only_edges, + key=lambda value: (rust_paths[0].get(value[0], sys.maxsize), value), + ) + candidates.append( + ( + rust_paths[0].get(edge[0], sys.maxsize), + "Rust-only", + edge, + rust, + ) + ) + if lean_only_edges: + lean_paths = shortest_paths(lean) + edge = min( + lean_only_edges, + key=lambda value: (lean_paths[0].get(value[0], sys.maxsize), value), + ) + candidates.append( + ( + lean_paths[0].get(edge[0], sys.maxsize), + "Lean-only", + edge, + lean, + ) + ) + _, side, (src, action, dst), graph = min(candidates) + paths = rust_paths if graph is rust else lean_paths + return ( + f"labeled edge mismatch ({len(rust_only_edges)} Rust-only, " + f"{len(lean_only_edges)} Lean-only); shortest source is {side}\n" + f"{describe_path(graph, src, paths)}\n" + f"missing edge action: {action}\ndestination: {dst}" + ) + + differing = { + key for key in rust_states if rust.valuations[key] != lean.valuations[key] + } + if differing: + rust_paths = shortest_paths(rust) + state = min( + differing, key=lambda key: (rust_paths[0].get(key, sys.maxsize), key) + ) + rust_bits = rust.valuations[state] + lean_bits = lean.valuations[state] + details = [ + f" {index + 1}. {name}: Rust={rust_bits[index]} Lean={lean_bits[index]}" + for index, name in enumerate(PROPERTY_NAMES) + if rust_bits[index] != lean_bits[index] + ] + return ( + f"property valuation mismatch in {len(differing)} states\n" + f"{describe_path(rust, state, rust_paths)}\n" + "\n".join(details) + ) + + return "canonical files differ despite identical graph content" + + +def compare(nodes: int, lean_dir: Path, rust_dir: Path, temporary: Path) -> Summary: + rust_path = temporary / f"rust-{nodes}.tsv" + lean_path = temporary / f"lean-{nodes}.tsv" + run( + [ + "cargo", + "run", + "--quiet", + "--", + "export", + "--nodes", + str(nodes), + "-o", + str(rust_path), + ], + rust_dir, + ) + run( + ["lake", "exe", "migration-exporter", "--nodes", str(nodes)], + lean_dir, + lean_path, + ) + rust_summary = validate(rust_path, nodes) + lean_summary = validate(lean_path, nodes) + if rust_summary != lean_summary or not filecmp.cmp( + rust_path, lean_path, shallow=False + ): + raise AssertionError(mismatch(load(rust_path), load(lean_path))) + expected = EXPECTED_COUNTS.get(nodes) + if expected is not None and (rust_summary.states, rust_summary.edges) != expected: + raise AssertionError( + f"n={nodes}: expected {expected[0]} states/{expected[1]} edges, " + f"found {rust_summary.states}/{rust_summary.edges}" + ) + return rust_summary + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Exhaustively compare Rust/Stateright and Lean legacy DR graphs" + ) + parser.add_argument("--nodes", type=int, nargs="+", default=[1, 2, 3]) + args = parser.parse_args() + if any(nodes < 1 for nodes in args.nodes): + parser.error("node counts must be positive") + + lean_dir = Path(__file__).resolve().parent + rust_dir = lean_dir.parents[1] / "tla" / "disaster-recovery" + try: + scratch = lean_dir / ".lake" + scratch.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="ccf-legacy-dr-", dir=scratch + ) as directory: + for nodes in args.nodes: + summary = compare(nodes, lean_dir, rust_dir, Path(directory)) + print( + f"n={nodes}: equivalent initial state, {summary.states} states, " + f"{summary.edges} labeled edges compared in both directions, " + f"{len(PROPERTY_NAMES)} valuations/state" + ) + except (AssertionError, OSError, RuntimeError, ValueError) as error: + print(f"equivalence failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lean/disaster-recovery-migration/lake-manifest.json b/lean/disaster-recovery-migration/lake-manifest.json new file mode 100644 index 000000000000..2fcc2ce77743 --- /dev/null +++ b/lean/disaster-recovery-migration/lake-manifest.json @@ -0,0 +1,138 @@ +{ + "version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": [ + { + "type": "path", + "scope": "", + "name": "disaster_recovery", + "manifestFile": "lake-manifest.json", + "inherited": false, + "dir": "../disaster-recovery", + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/axiom-audit.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "46024e005996495c65ef609368e11ab39c4222e3", + "name": "axiomAudit", + "manifestFile": "lake-manifest.json", + "inputRev": "46024e005996495c65ef609368e11ab39c4222e3", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/mathlib4.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "0df444a360eaa60ab8c11dca51a86af692955474", + "name": "mathlib", + "manifestFile": "lake-manifest.json", + "inputRev": "v4.33.1", + "inherited": true, + "configFile": "lakefile.lean" + }, + { + "url": "https://github.com/leanprover-community/plausible", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "b7eb3304aeae834b12dda98993a37f6a41f6f0bb", + "name": "plausible", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/LeanSearchClient", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "5f4d51b81cbd3f6b32b156bfad9056621a040404", + "name": "LeanSearchClient", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/import-graph", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "16f02aa7642864af59f1ff0e384a015994db9118", + "name": "importGraph", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/ProofWidgets4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "4be2e3d5087eeb272cf5a8853b8f9dd025ef5957", + "name": "proofwidgets", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean" + }, + { + "url": "https://github.com/leanprover-community/aesop", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "3448c0bcc5ce01b2d1546e483ec3620e32df3d0e", + "name": "aesop", + "manifestFile": "lake-manifest.json", + "inputRev": "master", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/quote4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "92c15be17b7caf78c2ad767ec40f89052d908d81", + "name": "Qq", + "manifestFile": "lake-manifest.json", + "inputRev": "master", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover-community/batteries", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "4488d40d070b9700d4d5a6aa342f0d40c31b2a2d", + "name": "batteries", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml" + }, + { + "url": "https://github.com/leanprover/lean4-cli", + "type": "git", + "subDir": null, + "scope": "leanprover", + "rev": "6130a47896ce867c6a4a55373441e59e565bad0f", + "name": "Cli", + "manifestFile": "lake-manifest.json", + "inputRev": "v4.33.0", + "inherited": true, + "configFile": "lakefile.toml" + } + ], + "name": "disaster_recovery_migration", + "lakeDir": ".lake", + "fixedToolchain": false +} diff --git a/lean/disaster-recovery-migration/lakefile.toml b/lean/disaster-recovery-migration/lakefile.toml new file mode 100644 index 000000000000..2096426c753f --- /dev/null +++ b/lean/disaster-recovery-migration/lakefile.toml @@ -0,0 +1,31 @@ +name = "disaster_recovery_migration" +version = "0.1.0" +moreLeanArgs = ["-DwarningAsError=true"] +# Quote the hyphenated executable name for Lean's name parser. +lintDriver = "axiomAudit/\u00abaxiom-audit\u00bb" +lintDriverArgs = ["--root", "DisasterRecoveryMigration"] +defaultTargets = [ + "DisasterRecoveryMigration", + "migration-model-checker", + "migration-semantic-checks", + "migration-exporter", +] + +[[require]] +name = "disaster_recovery" +path = "../disaster-recovery" + +[[lean_lib]] +name = "DisasterRecoveryMigration" + +[[lean_exe]] +name = "migration-model-checker" +root = "Main" + +[[lean_exe]] +name = "migration-semantic-checks" +root = "Tests" + +[[lean_exe]] +name = "migration-exporter" +root = "ExportMain" diff --git a/lean/disaster-recovery-migration/lean-toolchain b/lean/disaster-recovery-migration/lean-toolchain new file mode 100644 index 000000000000..a8afa7d1b02d --- /dev/null +++ b/lean/disaster-recovery-migration/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.33.1 diff --git a/tla/disaster-recovery/Readme.md b/tla/disaster-recovery/Readme.md index e337787e0027..d13a98b9ac84 100644 --- a/tla/disaster-recovery/Readme.md +++ b/tla/disaster-recovery/Readme.md @@ -9,3 +9,51 @@ The specification can be checked from the command line via `cargo run check`. However, a more useful UX is via the web-view which is hosted locally via `cargo run serve`. This allows you to explore the specification actions interactively, and the checker can be exhaustively run using the `Run to completion` button, which should find several useful examples of states where the network is opened, and where a deadlock is reached. + +## Exporting the state graph + +`cargo run --quiet -- export --nodes [-o ]` exhaustively enumerates the reachable +state graph (via the public `stateright::Model` interface, i.e. `init_states`/`next_steps`) +and writes it to `` (or stdout) in the shared `ccf-legacy-dr-graph-v1` TSV contract, so +it can be diffed against an independent re-implementation of the same model (e.g. in Python +or Lean). The encoder (`src/export.rs`) never uses `Debug` formatting, so output is insulated +from field order, hash-set iteration order, and library-version changes. Full grammar and +design notes are documented in the module doc comment at the top of `src/export.rs`; summary: + +```text +format ccf-legacy-dr-graph-v1 +nodes +init +state (one per reachable state, ascending ) +edge (one per reachable transition, sorted) +``` + +- `` is a dense integer (`0..N_STATES`) assigned by sorting every reachable state's + `` lexicographically -- _not_ BFS/discovery order -- so numbering is a pure + function of the reachable state set. Edges reference states only by `` (not by + repeating ``), since e.g. `n=3` already has 105,558 states / 552,282 edges and + repeating full state keys per edge does not scale. `edge` records are sorted by the tuple + `(, text, )` -- numeric on the ids, lexicographic on the action -- + and de-duplicated. +- `` is 9 chars of `1`/`0`, one per predicate registered via + `ActorModel::property` (`model.properties`), in registration order -- the exact same `fn` + pointers used by `check`/`serve`, so the export can never drift from their semantics. +- `` is `S([ACTORS],[TIMERS],[ENVELOPES])`: `ACTORS` are semicolon-separated + `s(PHASE,[GOSSIPS],[VOTES],SUBMITTED,txid)` records (`PHASE` in `vote`/`openjoin`/`open0`/ + `open1`/`join`), `TIMERS` are the ids of actors with an active election timeout, and + `ENVELOPES` are `e(src,dst,msg)#count` in flight. `history`/`random_choices`/ + `actor_storages` (always the unit value `()` for this model) and `crashed` (always all + `false`, since `max_crashes` is never set above `0`) are omitted, as they carry no + information here. +- `` is `deliver(src,dst,msg)` or `timeout(id,election)`. +- Set elements (`GOSSIPS`, `VOTES`) and `ENVELOPES` are ordered using the real Rust + `#[derive(Ord)]` implementations of `GossipStruct`/`VoteStruct`/`Envelope` (not a + string sort). +- Per `Model::next_state`'s "`None` = no-op" contract, only actions where `next_state` + returns `Some` produce an `edge` line (`Model::next_steps`'s default implementation + already filters these out). + +`--nodes` is an alias for the existing `--n-nodes`/`-n` flag, and (being a global clap +argument) is accepted either before or after the subcommand, so existing invocations +(`cargo run -- --n-nodes 3 check`, `cargo run check`, `cargo run -- --n-nodes 3 serve`) keep +working unchanged alongside `cargo run --quiet -- export --nodes `. diff --git a/tla/disaster-recovery/src/export.rs b/tla/disaster-recovery/src/export.rs new file mode 100644 index 000000000000..e62e4702c8dc --- /dev/null +++ b/tla/disaster-recovery/src/export.rs @@ -0,0 +1,377 @@ +//! Dependency-free canonical export of the reachable state graph. +//! +//! Implements the shared `ccf-legacy-dr-graph-v1` TSV contract: the model is +//! enumerated exhaustively using only the public `stateright::Model` +//! interface (`init_states`, `next_steps`, `within_boundary`), and every +//! state/action is serialized with an explicit hand-written grammar (never +//! `Debug`), so the output is stable across compiler/library versions and +//! diffable byte-for-byte against an independent re-implementation (e.g. +//! Python, Lean) of the same state machine. +//! +//! No new dependencies are introduced: only `stateright` (already a direct +//! dependency) and `std` are used. +//! +//! # Format +//! +//! ```text +//! format\tccf-legacy-dr-graph-v1 +//! nodes\t +//! init\t +//! state\t\t\t (one per reachable state) +//! edge\t\t\t (one per reachable transition) +//! ``` +//! +//! `` is a canonical, dense integer id (`0..N_STATES`) assigned by +//! sorting every reachable state's `` (see below) lexicographically +//! and numbering them in that order -- *not* BFS/discovery order -- so ids are +//! reproducible independent of traversal strategy. `state` records are +//! emitted in ascending `` order (equivalently, ascending `` +//! order). `edge` records are emitted sorted by the tuple +//! `(, text, )` (numeric on the ids, lexicographic on +//! the action text), and de-duplicated. Repeating the full `` in +//! every edge does not scale (e.g. `n=3` already has 105,558 states / 552,282 +//! edges), so edges reference states only by ``; a reader reconstructs the +//! `` for any `` via the `state` block. +//! +//! `` is exactly 9 characters of `1`/`0`, one per predicate +//! currently registered on the model via `ActorModel::property` +//! (`model.properties`), in registration order (liveness, then invariant, +//! then reachable properties -- *not* alphabetical). Each bit is the exact +//! existing `Property::condition` closure evaluated on that state, so the +//! export can never drift from `check`/`serve` behaviour, and preserves each +//! predicate's existing (sometimes misleadingly worded) name/meaning even +//! though names themselves are not repeated in the TSV output. +//! +//! Grammar for ``/`` tokens (no token contains whitespace): +//! +//! - gossip: `g(src,txid)` +//! - vote: `v(src,[GOSSIPS])` where `GOSSIPS` is a comma-separated gossip list +//! - msg: a gossip, a vote, or `o(id)` (`IAmOpen`) +//! - envelope: `e(src,dst,msg)` +//! - submitted vote: `none` or `some(dst,vote)` +//! - actor: `s(PHASE,[GOSSIPS],[VOTES],SUBMITTED,txid)` where `PHASE` is one +//! of `vote`, `openjoin`, `open0` (`Open { timeout: false }`), `open1` +//! (`Open { timeout: true }`), `join` +//! - global state: `S([ACTORS],[TIMERS],[ENVELOPES])` where `ACTORS` is +//! semicolon-separated (positional, by actor index), `TIMERS` is a +//! comma-separated list of actor ids with an active election timeout, and +//! `ENVELOPES` is a comma-separated list of `envelope#count` (count being +//! the in-flight multiplicity of that exact envelope) +//! - action: `deliver(src,dst,msg)` or `timeout(id,election)` +//! +//! `history` (`H = ()`), `random_choices` (`Node::Random = ()`), +//! `actor_storages` (`Node::Storage = ()`), and `crashed` (always all-`false`, +//! since `max_crashes` is never configured above `0`) are all omitted from +//! `S(...)`: for this model they are always constant/empty and carry no +//! information. +//! +//! Set elements (`GOSSIPS`, `VOTES`) are ordered using the actual Rust +//! `#[derive(Ord)]` implementation of `GossipStruct`/`VoteStruct` (not a +//! string sort), and `ENVELOPES` are ordered using `Envelope`'s derived +//! `Ord`. Per `Model::next_state`'s documented contract ("`None` indicates +//! the action does not change state"), only actions for which `next_state` +//! returns `Some` produce an edge; this is preserved by using +//! `Model::next_steps`, whose default implementation already filters out +//! `None` results. + +use crate::model::{GossipStruct, ModelCfg, Msg, NextStep, Node, State, Timer, VoteStruct}; +use stateright::actor::{ + ActorModel, ActorModelAction, ActorModelState, Envelope, Id, Network, Timers, +}; +use stateright::Model; +use std::collections::{HashMap, VecDeque}; +use std::io::{self, Write}; + +const PREDICATE_COUNT: usize = 9; + +fn fmt_id(id: Id) -> String { + usize::from(id).to_string() +} + +fn fmt_gossip(g: &GossipStruct) -> String { + format!("g({},{})", fmt_id(g.src), g.txid) +} + +/// Clones and sorts a gossip set using `GossipStruct`'s derived `Ord` +/// (compares `src` then `txid`), per the shared contract's "sort set +/// elements by Rust derived Ord". +fn sorted_gossips(set: &stateright::util::HashableHashSet) -> Vec { + let mut v: Vec = set.iter().cloned().collect(); + v.sort(); + v +} + +fn fmt_gossip_list(set: &stateright::util::HashableHashSet) -> String { + let items: Vec = sorted_gossips(set).iter().map(fmt_gossip).collect(); + format!("[{}]", items.join(",")) +} + +fn fmt_vote(v: &VoteStruct) -> String { + format!("v({},{})", fmt_id(v.src), fmt_gossip_list(&v.recv)) +} + +/// Clones and sorts a vote set using `VoteStruct`'s derived `Ord` (compares +/// `src` then `recv`). +fn sorted_votes(set: &stateright::util::HashableHashSet) -> Vec { + let mut v: Vec = set.iter().cloned().collect(); + v.sort(); + v +} + +fn fmt_vote_list(set: &stateright::util::HashableHashSet) -> String { + let items: Vec = sorted_votes(set).iter().map(fmt_vote).collect(); + format!("[{}]", items.join(",")) +} + +fn fmt_submitted(sv: &Option<(Id, VoteStruct)>) -> String { + match sv { + None => "none".to_string(), + Some((dst, vote)) => format!("some({},{})", fmt_id(*dst), fmt_vote(vote)), + } +} + +fn fmt_phase(n: &NextStep) -> &'static str { + match n { + NextStep::Vote => "vote", + NextStep::OpenJoin => "openjoin", + NextStep::Open { timeout: false } => "open0", + NextStep::Open { timeout: true } => "open1", + NextStep::Join => "join", + } +} + +fn fmt_actor(s: &State) -> String { + format!( + "s({},{},{},{},{})", + fmt_phase(&s.next_step), + fmt_gossip_list(&s.gossips), + fmt_vote_list(&s.votes), + fmt_submitted(&s.submitted_vote), + s.txid, + ) +} + +fn fmt_msg(m: &Msg) -> String { + match m { + Msg::Gossip(g) => fmt_gossip(g), + Msg::Vote(v) => fmt_vote(v), + Msg::IAmOpen(id) => format!("o({})", fmt_id(*id)), + } +} + +fn fmt_envelope(env: &Envelope) -> String { + format!( + "e({},{},{})", + fmt_id(env.src), + fmt_id(env.dst), + fmt_msg(&env.msg) + ) +} + +/// Tallies in-flight multiplicity per distinct envelope. `Network::iter_all` +/// yields one item per unit of multiplicity regardless of the underlying +/// `Network` variant (this model only ever uses +/// `new_unordered_nonduplicating`, whose internal representation already +/// tracks a count directly), so tallying via `iter_all` is variant-agnostic +/// and stays correct if the network configuration ever changes. +fn network_counts(network: &Network) -> Vec<(Envelope, usize)> { + let mut counts: HashMap, usize> = HashMap::new(); + for env in network.iter_all() { + *counts.entry(env.to_cloned_msg()).or_insert(0) += 1; + } + let mut v: Vec<(Envelope, usize)> = counts.into_iter().collect(); + // Envelope's derived Ord (src, dst, msg), per the shared contract. + v.sort_by(|a, b| a.0.cmp(&b.0)); + v +} + +fn fmt_network(network: &Network) -> String { + let items: Vec = network_counts(network) + .iter() + .map(|(env, count)| format!("{}#{}", fmt_envelope(env), count)) + .collect(); + format!("[{}]", items.join(",")) +} + +/// Comma-separated, ascending list of actor ids with an active election +/// timeout. `Timer` currently has a single variant, so presence alone is +/// significant (no timer-kind tag is emitted). +fn fmt_timers(timers_set: &[Timers]) -> String { + let mut ids: Vec = timers_set + .iter() + .enumerate() + .filter(|(_, t)| t.iter().next().is_some()) + .map(|(i, _)| i) + .collect(); + ids.sort_unstable(); + let items: Vec = ids.iter().map(|i| i.to_string()).collect(); + format!("[{}]", items.join(",")) +} + +/// Canonical `S(...)` encoding of a full `ActorModelState`. Used both +/// as the state field in `state` records and as the basis of the canonical +/// state id, so two independent implementations that compute the same +/// reachable state always produce the same key, regardless of traversal order. +pub fn fmt_state(state: &ActorModelState) -> String { + let actors: Vec = state.actor_states.iter().map(|s| fmt_actor(s)).collect(); + format!( + "S([{}],{},{})", + actors.join(";"), + fmt_timers(&state.timers_set), + fmt_network(&state.network), + ) +} + +/// Canonical encoding of an `ActorModelAction`. Only `Deliver` and `Timeout` +/// are part of the `ccf-legacy-dr-graph-v1` contract: this model never +/// produces `Drop` (`LossyNetwork::No`), `Crash`/`Recover` (`max_crashes == +/// 0`), or `SelectRandom` (no `Actor` ever issues a `ChooseRandom` command), +/// so encountering one is a bug (e.g. a future model config change) rather +/// than a case the contract needs to define. +pub fn fmt_action(action: &ActorModelAction) -> String { + match action { + ActorModelAction::Deliver { src, dst, msg } => { + format!( + "deliver({},{},{})", + fmt_id(*src), + fmt_id(*dst), + fmt_msg(msg) + ) + } + ActorModelAction::Timeout(id, Timer::ElectionTimeout) => { + format!("timeout({},election)", fmt_id(*id)) + } + _ => unreachable!( + "action variant is outside the ccf-legacy-dr-graph-v1 contract \ + (only Deliver/Timeout are ever produced by this model's configuration)" + ), + } +} + +/// The 9-character `1`/`0` bitstring for `state`, one bit per predicate +/// currently registered on `model` (`model.properties`) in registration +/// order -- i.e. exactly the same `fn` pointers used by `check`/`serve`, so +/// this can never drift from their semantics. +fn predicate_bitstring( + model: &ActorModel, + state: &ActorModelState, +) -> String { + model + .properties + .iter() + .map(|p| { + if (p.condition)(model, state) { + '1' + } else { + '0' + } + }) + .collect() +} + +/// Exhaustively enumerates the reachable state graph of `model` via the +/// public `stateright::Model` interface (`init_states`, `next_steps`, +/// `within_boundary`) and writes it to `out` as `ccf-legacy-dr-graph-v1`. +/// +/// States are discovered by BFS (for traversal only), but ``s are +/// assigned afterwards by sorting all discovered ``s +/// lexicographically -- so the numbering is a pure function of the reachable +/// state set, independent of traversal order. Edges reference states by +/// `` only, keeping output size linear in (states + edges) rather than +/// (edges * average state size). +pub fn export_graph( + model: &ActorModel, + out: &mut W, +) -> io::Result<()> { + assert_eq!( + model.properties.len(), + PREDICATE_COUNT, + "ccf-legacy-dr-graph-v1 requires exactly {PREDICATE_COUNT} registered model properties" + ); + + // Indexed by BFS discovery order (a "discovery id"); remapped to the + // canonical sorted-key id only once the full state set is known. + let mut visited: HashMap, usize> = HashMap::new(); + let mut keys: Vec = Vec::new(); + let mut bits: Vec = Vec::new(); + let mut frontier: VecDeque> = VecDeque::new(); + // (discovery src id, action text, discovery dst id) + let mut edges: Vec<(usize, String, usize)> = Vec::new(); + + let mut init_states = model.init_states(); + assert_eq!( + init_states.len(), + 1, + "ccf-legacy-dr-graph-v1 assumes a single deterministic init state" + ); + let init_state = init_states.remove(0); + assert!( + model.within_boundary(&init_state), + "ccf-legacy-dr-graph-v1 assumes the init state is within the model boundary" + ); + let init_discovery_id = keys.len(); + keys.push(fmt_state(&init_state)); + bits.push(predicate_bitstring(model, &init_state)); + visited.insert(init_state.clone(), init_discovery_id); + frontier.push_back(init_state); + + while let Some(s) = frontier.pop_front() { + let src_discovery_id = *visited + .get(&s) + .expect("every frontier state was inserted into `visited` before being queued"); + // `next_steps` (default `Model` trait method) already filters out + // actions for which `next_state` returns `None`, preserving the + // documented no-op-suppression contract. + for (action, ns) in model.next_steps(&s) { + if !model.within_boundary(&ns) { + continue; + } + let action_key = fmt_action(&action); + let dst_discovery_id = if let Some(&id) = visited.get(&ns) { + id + } else { + let id = keys.len(); + keys.push(fmt_state(&ns)); + bits.push(predicate_bitstring(model, &ns)); + visited.insert(ns.clone(), id); + frontier.push_back(ns); + id + }; + edges.push((src_discovery_id, action_key, dst_discovery_id)); + } + } + + // Canonical id assignment: number every discovered state by the + // lexicographic order of its ``, not by discovery order. + let mut order: Vec = (0..keys.len()).collect(); + order.sort_by(|&a, &b| keys[a].cmp(&keys[b])); + let mut canonical_id: Vec = vec![0; keys.len()]; + for (id, &discovery_id) in order.iter().enumerate() { + canonical_id[discovery_id] = id; + } + + // Remap edges to canonical ids, then sort by (SRC_ID, ACTION, DST_ID) -- + // numeric on the ids (real `usize` comparison, not string comparison), + // lexicographic on the action text -- and de-duplicate. + let mut canonical_edges: Vec<(usize, String, usize)> = edges + .into_iter() + .map(|(src, action, dst)| (canonical_id[src], action, canonical_id[dst])) + .collect(); + canonical_edges.sort(); + canonical_edges.dedup(); + + writeln!(out, "format\tccf-legacy-dr-graph-v1")?; + writeln!(out, "nodes\t{}", model.actors.len())?; + writeln!(out, "init\t{}", canonical_id[init_discovery_id])?; + for (id, &discovery_id) in order.iter().enumerate() { + writeln!( + out, + "state\t{}\t{}\t{}", + id, keys[discovery_id], bits[discovery_id] + )?; + } + for (src, action, dst) in &canonical_edges { + writeln!(out, "edge\t{src}\t{action}\t{dst}")?; + } + Ok(()) +} diff --git a/tla/disaster-recovery/src/main.rs b/tla/disaster-recovery/src/main.rs index d92767d1a0f0..15bcef28f7ad 100644 --- a/tla/disaster-recovery/src/main.rs +++ b/tla/disaster-recovery/src/main.rs @@ -1,7 +1,9 @@ extern crate clap; extern crate stateright; use clap::Parser; +mod export; mod model; +use export::export_graph; use model::{ModelCfg, Msg, NextStep, Node, State}; use stateright::{actor::*, report::WriteReporter, util::HashableHashSet, Checker, Model}; use std::sync::Arc; @@ -198,7 +200,11 @@ fn properties(model: ActorModel) -> ActorModel, + }, } fn check(model: ActorModel) { @@ -227,6 +241,21 @@ fn serve(model: ActorModel) { checker.serve("localhost:8080"); } +fn export(model: ActorModel, out: Option) { + match out { + Some(path) => { + let mut file = std::fs::File::create(&path) + .unwrap_or_else(|e| panic!("failed to create '{}': {}", path, e)); + export_graph(&model, &mut file).expect("failed to write model export"); + } + None => { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + export_graph(&model, &mut handle).expect("failed to write model export"); + } + } +} + fn main() { let args = CliArgs::parse(); @@ -240,5 +269,6 @@ fn main() { match args.command { Commands::Check => check(model), Commands::Serve => serve(model), + Commands::Export { out } => export(model, out), } } From 6a33609433f5970fc6acb95c2a20967ea51409cd Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 11 Sep 2026 14:42:00 +0100 Subject: [PATCH 06/15] Remove the superseded Stateright disaster recovery model (#8280) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/README.md | 6 +- .github/workflows/ci-verification.yml | 22 - .github/workflows/lean.yml | 53 -- .../DisasterRecoveryMigration.lean | 3 - .../Legacy/Checker.lean | 152 ----- .../Legacy/Model.lean | 392 ------------ .../DisasterRecoveryMigration/Refinement.lean | 335 ---------- .../ExportMain.lean | 24 - lean/disaster-recovery-migration/Main.lean | 23 - lean/disaster-recovery-migration/README.md | 113 ---- lean/disaster-recovery-migration/Tests.lean | 72 --- lean/disaster-recovery-migration/compare.py | 358 ----------- .../lake-manifest.json | 138 ---- .../disaster-recovery-migration/lakefile.toml | 31 - .../lean-toolchain | 1 - tla/disaster-recovery/.gitignore | 1 - tla/disaster-recovery/Cargo.lock | 592 ------------------ tla/disaster-recovery/Cargo.toml | 7 - tla/disaster-recovery/Readme.md | 59 -- tla/disaster-recovery/src/export.rs | 377 ----------- tla/disaster-recovery/src/main.rs | 274 -------- tla/disaster-recovery/src/model.rs | 193 ------ 22 files changed, 1 insertion(+), 3225 deletions(-) delete mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration.lean delete mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean delete mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean delete mode 100644 lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean delete mode 100644 lean/disaster-recovery-migration/ExportMain.lean delete mode 100644 lean/disaster-recovery-migration/Main.lean delete mode 100644 lean/disaster-recovery-migration/README.md delete mode 100644 lean/disaster-recovery-migration/Tests.lean delete mode 100755 lean/disaster-recovery-migration/compare.py delete mode 100644 lean/disaster-recovery-migration/lake-manifest.json delete mode 100644 lean/disaster-recovery-migration/lakefile.toml delete mode 100644 lean/disaster-recovery-migration/lean-toolchain delete mode 100644 tla/disaster-recovery/.gitignore delete mode 100644 tla/disaster-recovery/Cargo.lock delete mode 100644 tla/disaster-recovery/Cargo.toml delete mode 100644 tla/disaster-recovery/Readme.md delete mode 100644 tla/disaster-recovery/src/export.rs delete mode 100644 tla/disaster-recovery/src/main.rs delete mode 100644 tla/disaster-recovery/src/model.rs diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f01110a2aa77..c7b21e104652 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -79,7 +79,7 @@ File: `codeql-analysis.yml` # Continuous Verification -Runs the standard model checking, simulation, trace validation, counterexample, and disaster recovery jobs each week. +Runs the standard model checking, simulation, trace validation, and counterexample jobs each week. File: `ci-verification.yml` 3rd party dependencies: None @@ -114,10 +114,6 @@ and the proof implementation files marked as generated for review purposes. The standard `mk_all --check` command ensures that the audit root imports every library module, so newly added proofs cannot silently escape the checks. -The temporary migration-evidence job builds and audits the Lean mirror of the -legacy Rust/Stateright disaster recovery model, exercises both implementations, -and exhaustively compares their complete graphs for up to three nodes. - File: `lean.yml` 3rd party dependencies: None diff --git a/.github/workflows/ci-verification.yml b/.github/workflows/ci-verification.yml index 1a72ca4feb95..12c03e553f14 100644 --- a/.github/workflows/ci-verification.yml +++ b/.github/workflows/ci-verification.yml @@ -242,25 +242,3 @@ jobs: name: tlc-trace-validation-consensus path: | tla/traces/* - - model-checking-self-healing-open: - name: Model Checking - Self-Healing Open - runs-on: [self-hosted, 1ES.Pool=gha-vmss-d16av6-ci] - container: - image: mcr.microsoft.com/azurelinux/base/core:3.0 - options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE - - steps: - - name: "Checkout dependencies" - shell: bash - run: | - gpg --import /etc/pki/rpm-gpg/MICROSOFT-RPM-GPG-KEY - tdnf -y update - tdnf -y install ca-certificates git - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install Stateright dependencies - run: | - tdnf install -y cargo - - - run: cd tla/disaster-recovery && cargo run check diff --git a/.github/workflows/lean.yml b/.github/workflows/lean.yml index ad2c53671133..d34e5678487f 100644 --- a/.github/workflows/lean.yml +++ b/.github/workflows/lean.yml @@ -4,7 +4,6 @@ on: pull_request: paths: - "lean/**" - - "tla/disaster-recovery/**" - ".github/workflows/lean.yml" concurrency: @@ -46,55 +45,3 @@ jobs: lake build --wfail lake lint lake exe canonical-checks - - migration-evidence: - name: Temporary migration evidence - runs-on: ubuntu-latest - timeout-minutes: 90 - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Lean and Rust - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y elan - elan toolchain install "$(cat lean/disaster-recovery-migration/lean-toolchain)" - rustup toolchain install stable --profile minimal - rustup default stable - - - name: Build and check Rust model - working-directory: tla/disaster-recovery - shell: bash - run: | - set -euo pipefail - cargo check --locked - cargo build --locked - cargo run --quiet --locked -- --nodes 2 check - - - name: Restore Mathlib cache - working-directory: lean/disaster-recovery-migration - shell: bash - run: | - set -euo pipefail - lake exe cache get - - - name: Build and check migration model - working-directory: lean/disaster-recovery-migration - shell: bash - run: | - set -euo pipefail - lake exe mk_all --check --lib DisasterRecoveryMigration - lake build --wfail - lake lint - lake exe migration-semantic-checks - lake exe migration-model-checker --nodes 3 - - - name: Compare complete Rust and Lean graphs - working-directory: lean/disaster-recovery-migration - shell: bash - run: | - set -euo pipefail - python3 compare.py --nodes 1 2 3 diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration.lean deleted file mode 100644 index f17bd8ffc407..000000000000 --- a/lean/disaster-recovery-migration/DisasterRecoveryMigration.lean +++ /dev/null @@ -1,3 +0,0 @@ -import DisasterRecoveryMigration.Legacy.Checker -import DisasterRecoveryMigration.Legacy.Model -import DisasterRecoveryMigration.Refinement diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean deleted file mode 100644 index 659daef03546..000000000000 --- a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Checker.lean +++ /dev/null @@ -1,152 +0,0 @@ -import DisasterRecoveryMigration.Legacy.Model - -namespace DisasterRecoveryMigration.Legacy - -structure Edge where - src : Nat - action : Action - dst : Nat -deriving Repr, BEq - -structure Graph where - states : Array GlobalState - edges : Array Edge - parents : Array (Option (Prod Nat Action)) - -def enumerate (n : Nat) : IO Graph := do - let initial := initialState n - let mut states := #[initial] - let mut edges := #[] - let mut parents : Array (Option (Prod Nat Action)) := #[none] - let mut seen : Std.HashMap String Nat := {} - seen := seen.insert (stateKey initial) 0 - let mut cursor := 0 - while cursor < states.size do - let state := states[cursor]! - for action in actions state do - match nextState n state action with - | none => pure () - | some next => - let key := stateKey next - let (dst, discovered) := - match seen[key]? with - | some index => (index, false) - | none => (states.size, true) - if discovered then - seen := seen.insert key dst - states := states.push next - parents := parents.push (some (cursor, action)) - edges := edges.push { src := cursor, action, dst } - cursor := cursor + 1 - pure { states, edges, parents } - -def valuationBits (values : Array Bool) : String := - String.ofList (values.toList.map fun value => if value then '1' else '0') - -private structure ExportEdge where - src : Nat - action : String - dst : Nat - -private def exportEdgeLE (left right : ExportEdge) : Bool := - left.src < right.src || - (left.src == right.src && - (left.action < right.action || - (left.action == right.action && left.dst <= right.dst))) - -private def traceTo (graph : Graph) (target : Nat) : List Action := - let rec collect (index : Nat) (fuel : Nat) (suffix : List Action) : List Action := - match fuel with - | 0 => suffix - | fuel + 1 => - match graph.parents[index]? |>.bind id with - | none => suffix - | some (parent, action) => collect parent fuel (action :: suffix) - collect target graph.states.size [] - -private def printTrace (graph : Graph) (target : Nat) : IO Unit := do - let trace := traceTo graph target - if trace.isEmpty then - IO.eprintln " trace: " - else - for (action, step) in trace.zipIdx do - IO.eprintln s!" {step + 1}. {actionKey action}" - -private def eventuallyGood (graph : Graph) (property : Nat) : Array Bool := - Id.run do - let mut good := - graph.states.map fun state => (legacyValuations state.actors.size state)[property]! - let mut remaining := Array.replicate graph.states.size 0 - let mut predecessors : Array (List Nat) := Array.replicate graph.states.size [] - for edge in graph.edges do - remaining := remaining.modify edge.src (fun count => count + 1) - predecessors := predecessors.modify edge.dst (fun values => edge.src :: values) - let mut queue := #[] - for index in List.range good.size do - if good[index]! then queue := queue.push index - let mut cursor := 0 - while cursor < queue.size do - let resolved := queue[cursor]! - for predecessor in predecessors[resolved]! do - if !good[predecessor]! then - remaining := remaining.modify predecessor (fun count => count - 1) - if remaining[predecessor]! == 0 then - good := good.set! predecessor true - queue := queue.push predecessor - cursor := cursor + 1 - return good - -def checkGraph (n : Nat) (graph : Graph) : IO Bool := do - IO.eprintln s!"reachable states: {graph.states.size}, transitions: {graph.edges.size}" - let mut passed := true - for property in List.range legacyPropertyNames.size do - let name := legacyPropertyNames[property]! - let expectation := legacyExpectations[property]! - let values := graph.states.map fun state => (legacyValuations n state)[property]! - let eventual := if expectation == "eventually" then eventuallyGood graph property else #[] - let result := - if expectation == "always" then values.all id - else if expectation == "sometimes" then values.any id - else eventual[0]! - IO.eprintln s!"{if result then "PASS" else "FAIL"} [{expectation}] {name}" - if result && expectation == "sometimes" then - match (List.range values.size).find? (fun index => values[index]!) with - | none => pure () - | some index => - IO.eprintln " shortest example:" - printTrace graph index - else if !result then - passed := false - let witness := - if expectation == "always" then - (List.range values.size).find? fun index => !values[index]! - else if expectation == "sometimes" then - some 0 - else - (List.range values.size).find? fun index => - !eventual[index]! - match witness with - | none => IO.eprintln " no reachable example" - | some index => printTrace graph index - pure passed - -def exportGraph (n : Nat) (graph : Graph) : IO Unit := do - let canonical := (graph.states.toList.zipIdx.map fun (state, bfsId) => - (stateKey state, bfsId)).mergeSort (fun left right => left.1 <= right.1) - let mut ids := Array.replicate graph.states.size 0 - for ((_, bfsId), canonicalId) in canonical.zipIdx do - ids := ids.set! bfsId canonicalId - IO.println "format\tccf-legacy-dr-graph-v1" - IO.println s!"nodes\t{n}" - IO.println s!"init\t{ids[0]!}" - for ((key, bfsId), canonicalId) in canonical.zipIdx do - IO.println s!"state\t{canonicalId}\t{key}\t{valuationBits (legacyValuations n graph.states[bfsId]!)}" - let canonicalEdges := (graph.edges.toList.map fun edge => { - src := ids[edge.src]! - action := actionKey edge.action - dst := ids[edge.dst]! - }).mergeSort exportEdgeLE - for edge in canonicalEdges do - IO.println s!"edge\t{edge.src}\t{edge.action}\t{edge.dst}" - -end DisasterRecoveryMigration.Legacy diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean deleted file mode 100644 index 91000ae2f1b5..000000000000 --- a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Legacy/Model.lean +++ /dev/null @@ -1,392 +0,0 @@ -import Std - -namespace DisasterRecoveryMigration.Legacy - -abbrev Id := Nat -abbrev Txid := Nat - -structure Gossip where - src : Id - txid : Txid -deriving Repr, BEq, Hashable - -structure Vote where - src : Id - recv : List Gossip -deriving Repr, BEq, Hashable - -inductive Msg where - | gossip (value : Gossip) - | vote (value : Vote) - | iAmOpen (src : Id) -deriving Repr, BEq, Hashable - -inductive Phase where - | vote - | openJoin - | open (timeout : Bool) - | join -deriving Repr, BEq, Hashable, Inhabited - -structure ActorState where - nextStep : Phase - gossips : List Gossip - votes : List Vote - submittedVote : Option (Prod Id Vote) - txid : Txid -deriving Repr, BEq, Hashable, Inhabited - -structure Envelope where - src : Id - dst : Id - msg : Msg -deriving Repr, BEq, Hashable - -structure GlobalState where - actors : Array ActorState - timers : Array Bool - network : List Envelope -deriving Repr, BEq, Hashable, Inhabited - -inductive Action where - | deliver (envelope : Envelope) - | timeout (id : Id) -deriving Repr, BEq, Hashable - -structure Output where - sent : List (Prod Id Msg) := [] - setTimer : Bool := false -deriving Repr, BEq - -private def comma (values : List String) : String := - String.intercalate "," values - -def gossipKey (gossip : Gossip) : String := - s!"g({gossip.src},{gossip.txid})" - -def voteKey (vote : Vote) : String := - s!"v({vote.src},[{comma (vote.recv.map gossipKey)}])" - -def msgKey : Msg -> String - | .gossip gossip => gossipKey gossip - | .vote vote => voteKey vote - | .iAmOpen src => s!"o({src})" - -def envelopeKey (envelope : Envelope) : String := - s!"e({envelope.src},{envelope.dst},{msgKey envelope.msg})" - -def phaseKey : Phase -> String - | .vote => "vote" - | .openJoin => "openjoin" - | .open false => "open0" - | .open true => "open1" - | .join => "join" - -def submittedKey : Option (Prod Id Vote) -> String - | none => "none" - | some (dst, vote) => s!"some({dst},{voteKey vote})" - -def actorKey (actor : ActorState) : String := - s!"s({phaseKey actor.nextStep},[{comma (actor.gossips.map gossipKey)}],[{comma (actor.votes.map voteKey)}],{submittedKey actor.submittedVote},{actor.txid})" - -private def networkRunsFrom (current : Envelope) (count : Nat) : - List Envelope -> List (Prod Envelope Nat) - | [] => [(current, count)] - | head :: tail => - if head == current then - networkRunsFrom current (count + 1) tail - else - (current, count) :: networkRunsFrom head 1 tail - -private def networkRuns : List Envelope -> List (Prod Envelope Nat) - | [] => [] - | head :: tail => networkRunsFrom head 1 tail - -def stateKey (state : GlobalState) : String := - let actors := String.intercalate ";" (state.actors.toList.map actorKey) - let timers := comma (((List.range state.timers.size).filter - (fun id => state.timers[id]!)).map toString) - let network := comma ((networkRuns state.network).map fun (env, count) => - s!"{envelopeKey env}#{count}") - s!"S([{actors}],[{timers}],[{network}])" - -def actionKey : Action -> String - | .deliver env => s!"deliver({env.src},{env.dst},{msgKey env.msg})" - | .timeout id => s!"timeout({id},election)" - -private def insertSorted (before : a -> a -> Bool) (value : a) : List a -> List a - | [] => [value] - | head :: tail => - if before value head then - value :: head :: tail - else - head :: insertSorted before value tail - -private def insertUniqueSorted [BEq a] (before : a -> a -> Bool) (value : a) (values : List a) : - List a := - if values.contains value then values else insertSorted before value values - -private def removeOne [BEq a] (value : a) : List a -> List a - | [] => [] - | head :: tail => if head == value then tail else head :: removeOne value tail - -private def gossipGreater (left right : Gossip) : Bool := - right.txid < left.txid || (right.txid == left.txid && right.src < left.src) - -private def gossipBefore (left right : Gossip) : Bool := - left.src < right.src || (left.src == right.src && left.txid < right.txid) - -private def gossipListBefore : List Gossip -> List Gossip -> Bool - | [], [] => false - | [], _ :: _ => true - | _ :: _, [] => false - | left :: leftTail, right :: rightTail => - if left == right then gossipListBefore leftTail rightTail - else gossipBefore left right - -private def voteBefore (left right : Vote) : Bool := - left.src < right.src || (left.src == right.src && gossipListBefore left.recv right.recv) - -private def msgBefore : Msg -> Msg -> Bool - | .gossip left, .gossip right => gossipBefore left right - | .gossip _, _ => true - | .vote _, .gossip _ => false - | .vote left, .vote right => voteBefore left right - | .vote _, .iAmOpen _ => true - | .iAmOpen _, .gossip _ => false - | .iAmOpen _, .vote _ => false - | .iAmOpen left, .iAmOpen right => left < right - -private def envelopeBefore (left right : Envelope) : Bool := - left.src < right.src || - (left.src == right.src && - (left.dst < right.dst || (left.dst == right.dst && msgBefore left.msg right.msg))) - -private def maximumGossip : List Gossip -> Option Gossip - | [] => none - | head :: tail => - some (tail.foldl (fun current candidate => - if gossipGreater candidate current then candidate else current) head) - -private def voteForMax (gossips : List Gossip) (id : Id) : Option (Prod Id Vote) := do - let maximum <- maximumGossip gossips - pure (maximum.src, { src := id, recv := gossips }) - -private def otherPeers (n id : Nat) : List Id := - (List.range n).filter (fun peer => peer != id) - -private def advanceStep (n id : Nat) (timeout : Bool) (state : ActorState) : - Prod ActorState (Prod Output Bool) := - match state.nextStep with - | .vote => - if state.gossips.length == n || timeout then - match voteForMax state.gossips id with - | none => (state, {}, false) - | some (dst, vote) => - let next := { - state with - nextStep := .openJoin - submittedVote := some (dst, vote) - votes := if dst == id then insertUniqueSorted voteBefore vote state.votes else state.votes - } - let sent := if dst == id then [] else [(dst, Msg.vote vote)] - (next, { sent }, true) - else - (state, {}, false) - | .openJoin => - if state.votes.length >= (n + 1) / 2 || timeout then - let sent := (otherPeers n id).map (fun peer => (peer, Msg.iAmOpen id)) - ({ state with nextStep := .open timeout }, { sent }, true) - else - (state, {}, false) - | _ => (state, {}, false) - -def advanceSeveral (n id : Nat) (timeout : Bool) (state : ActorState) : - Prod ActorState Output := - let (state1, output1, advanced1) := advanceStep n id timeout state - if advanced1 then - let (state2, output2, _) := advanceStep n id timeout state1 - (state2, { sent := output1.sent ++ output2.sent }) - else - (state, {}) - -def onMessage (n id : Nat) (state : ActorState) (msg : Msg) : - Option (Prod ActorState Output) := - let received := - match msg with - | .gossip gossip => - if !state.gossips.contains gossip && state.submittedVote.isNone then - { state with gossips := insertUniqueSorted gossipBefore gossip state.gossips } - else - state - | .vote vote => - { state with votes := insertUniqueSorted voteBefore vote state.votes } - | .iAmOpen _ => - match state.nextStep with - | .open _ => state - | _ => { state with nextStep := .join } - let (next, output) := advanceSeveral n id false received - some (next, output) - -def onTimeout (n id : Nat) (state : ActorState) : Option (Prod ActorState Output) := - match state.nextStep with - | .vote => - if state.gossips.isEmpty then none - else - let (next, output) := advanceSeveral n id true state - some (next, { output with setTimer := true }) - | .openJoin => - if state.votes.isEmpty then none - else some (advanceSeveral n id true state) - | _ => none - -private def applyOutput (src : Id) (output : Output) (state : GlobalState) : GlobalState := - let network := output.sent.foldl - (fun current (dst, msg) => insertSorted envelopeBefore { src, dst, msg } current) - state.network - let timers := if output.setTimer then state.timers.set! src true else state.timers - { state with network, timers } - -private def startActor (n id : Nat) : Prod ActorState Output := - let gossip := { src := id, txid := id } - let initial : ActorState := { - nextStep := .vote - gossips := [gossip] - votes := [] - submittedVote := none - txid := id - } - let output : Output := { - sent := (otherPeers n id).map (fun peer => (peer, Msg.gossip gossip)) - setTimer := true - } - let (state, advanced) := advanceSeveral n id false initial - (state, { sent := output.sent ++ advanced.sent, setTimer := true }) - -def initialState (n : Nat) : GlobalState := - (List.range n).foldl (fun global id => - let (actor, output) := startActor n id - let withActor := { - global with - actors := global.actors.push actor - timers := global.timers.push false - } - applyOutput id output withActor) - { actors := #[], timers := #[], network := [] } - -private def distinctNetworkFrom (previous : Envelope) : List Envelope -> List Envelope - | [] => [] - | head :: tail => - if head == previous then - distinctNetworkFrom previous tail - else - head :: distinctNetworkFrom head tail - -private def distinctNetwork : List Envelope -> List Envelope - | [] => [] - | head :: tail => head :: distinctNetworkFrom head tail - -def actions (state : GlobalState) : List Action := - (distinctNetwork state.network).map Action.deliver ++ - ((List.range state.timers.size).filter - (fun id => state.timers[id]!)).map Action.timeout - -def nextState (n : Nat) (state : GlobalState) : Action -> Option GlobalState - | .deliver envelope => do - let actor <- state.actors[envelope.dst]? - let (nextActor, output) <- onMessage n envelope.dst actor envelope.msg - let delivered := { - state with - actors := state.actors.set! envelope.dst nextActor - network := removeOne envelope state.network - } - pure (applyOutput envelope.dst output delivered) - | .timeout id => do - guard (state.timers[id]?.getD false) - let actor <- state.actors[id]? - let (nextActor, output) <- onTimeout n id actor - let expired := { - state with - actors := state.actors.set! id nextActor - timers := state.timers.set! id false - } - pure (applyOutput id output expired) - -def reachedOpen (state : GlobalState) : Bool := - state.actors.any fun actor => - match actor.nextStep with - | .open _ => true - | _ => false - -def reachedOpenTimeout (state : GlobalState) (expected : Bool) : Bool := - state.actors.any fun actor => actor.nextStep == .open expected - -def unanimousVotes (n : Nat) (state : GlobalState) : Bool := - state.actors.all fun actor => - match actor.submittedVote with - | none => false - | some (_, vote) => - (List.range n).all fun peer => vote.recv.any (fun gossip => gossip.src == peer) - -def majorityHaveSameMaximum (state : GlobalState) : Bool := - let chosen := state.actors.toList.filterMap fun actor => do - let (_, vote) <- actor.submittedVote - let maximum <- maximumGossip vote.recv - pure maximum.src - let chosen := chosen.foldl (fun values id => - insertSorted (fun left right => left < right) id values) [] - let majorityIndex := state.actors.size / 2 - match chosen[majorityIndex]? with - | none => false - | some majority => (chosen.take majorityIndex).all (fun chosen => chosen == majority) - -private def implies (left right : Bool) : Bool := - !left || right - -def legacyValuations (n : Nat) (state : GlobalState) : Array Bool := - let openCount := state.actors.countP fun actor => - match actor.nextStep with - | .open _ => true - | _ => false - let allOpenJoin := state.actors.all (fun actor => actor.nextStep == .openJoin) - let allVotesDelivered := !state.network.any fun envelope => - match envelope.msg with - | .vote _ => true - | _ => false - let majorityIndex := state.actors.size / 2 - let commitTxid := (state.actors[majorityIndex]!).txid - let persisted := state.actors.all fun actor => - match actor.nextStep with - | .open _ => actor.txid >= commitTxid - | _ => true - #[ - implies (unanimousVotes n state) (reachedOpenTimeout state false), - reachedOpen state, - implies (majorityHaveSameMaximum state) (reachedOpenTimeout state false), - implies (!reachedOpenTimeout state true) (openCount <= 1), - !(allOpenJoin && allVotesDelivered), - implies (!reachedOpenTimeout state true) persisted, - implies (state.actors.size > 1) (reachedOpen state), - reachedOpenTimeout state true, - majorityHaveSameMaximum state && reachedOpenTimeout state false - ] - -def legacyPropertyNames : Array String := #[ - "Unanimous votes => no chance of a fork", - "Open", - "Majority votes => no fork", - "No open with timeout, no fork", - "Deadlock", - "Persist committed txs", - "Open is possible", - "Unsafe open with timeout", - "Majority vote still opens without timeout" -] - -def legacyExpectations : Array String := #[ - "eventually", "eventually", "eventually", - "always", "always", "always", - "sometimes", "sometimes", "sometimes" -] - -end DisasterRecoveryMigration.Legacy diff --git a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean b/lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean deleted file mode 100644 index 6a3a74ccb613..000000000000 --- a/lean/disaster-recovery-migration/DisasterRecoveryMigration/Refinement.lean +++ /dev/null @@ -1,335 +0,0 @@ -import DisasterRecoveryMigration.Legacy.Model -import DisasterRecovery.Protocol.Model -import Mathlib.Logic.Relation - -namespace DisasterRecoveryMigration.Refinement - -open DisasterRecovery.Protocol.Model - -def projectPhase (state : NodeState) : DisasterRecoveryMigration.Legacy.Phase := - match state.phase with - | .gossiping => .vote - | .voting => .openJoin - | .opening | .open => - match state.openKind with - | some .failover => .open true - | _ => .open false - | .joining => .join - -inductive LegacyAtomic : - DisasterRecoveryMigration.Legacy.Phase -> DisasterRecoveryMigration.Legacy.Phase -> Prop where - | gossipToVoting : LegacyAtomic .vote .openJoin - | quorumOpen : LegacyAtomic .openJoin (.open false) - | failoverOpen : LegacyAtomic .openJoin (.open true) - | gossipToJoin : LegacyAtomic .vote .join - | votingToJoin : LegacyAtomic .openJoin .join - -abbrev LegacyWeakStep := - Relation.ReflTransGen LegacyAtomic - -def embeddedTxID - (config : Config) - (source : Location) - (txid : TxID) : Prop := - txid.view = 0 /\ config.expectedLocations[txid.seqno]? = some source - -structure LegacyDataAssumptions - (config : Config) - (event : Event) - (after : NodeState) : Prop where - /-- Recorded for a future data refinement; phase simulation does not assume it. -/ - oddNodeCount : - exists half, config.expectedLocations.length = 2 * half + 1 - acceptedExpectedInput : - match event with - | .receiveGossip source txid validation => - validation = .accepted /\ - expectedSource config source = true /\ - embeddedTxID config source txid - | .receiveVote source validation => - validation = .accepted /\ expectedSource config source = true - | .receiveIAmOpen source validation => - validation = .accepted /\ expectedSource config source = true - | .timeout | .retry => True - quorumOnly : - after.openKind != some .failover - -structure CompatibilityStep - (config : Config) - (before : NodeState) - (event : Event) - (after : NodeState) : Prop where - canonical : - after = (step config before event).state - -private theorem advance_simulates - (config : Config) - (state : NodeState) - (timeout : Bool) - (output : StepOutput) - (advanced : advance config state timeout = some output) : - LegacyWeakStep (projectPhase state) (projectPhase output.state) := by - cases timeout <;> cases phase : state.phase <;> - simp [advance, phase] at advanced <;> - repeat' split at advanced <;> - simp_all [projectPhase, advanceTimeoutLane, advanceTimeoutState] - all_goals subst output - all_goals simp_all [projectPhase, advanceTimeoutLane] - all_goals - first - | (split <;> simp_all) - | skip - all_goals - first - | exact .refl - | exact .single .gossipToVoting - | exact .single .quorumOpen - | exact .single .failoverOpen - -private theorem receive_gossip_simulates - (config : Config) - (before : NodeState) - (source : Location) - (txid : TxID) - (validation : Validation) : - LegacyWeakStep - (projectPhase before) - (projectPhase - (step config before (.receiveGossip source txid validation)).state) := by - cases validation with - | rejected => - simp [step, rejected] - exact .refl - | accepted => - by_cases frozen : before.chosen != none - case pos => - simp [step, frozen, rejected] - exact .refl - case neg => - let received := { - before with gossips := insertGossip source txid before.gossips } - have same : projectPhase received = projectPhase before := by - simp [received, projectPhase] - cases advanced : advance config received false with - | none => - simp [step, frozen, received, advanced, rejected] - exact .refl - | some output => - simp [step, frozen, received, advanced] - have simulation := - advance_simulates config received false output advanced - rw [same] at simulation - exact simulation - -private theorem receive_vote_simulates - (config : Config) - (before : NodeState) - (source : Location) - (validation : Validation) : - LegacyWeakStep - (projectPhase before) - (projectPhase - (step config before (.receiveVote source validation)).state) := by - cases validation with - | rejected => - simp [step, rejected] - exact .refl - | accepted => - let received := { before with votes := insertVote source before.votes } - have same : projectPhase received = projectPhase before := by - simp [received, projectPhase] - cases advanced : advance config received false with - | none => - simp [step, received, advanced, rejected] - exact .refl - | some output => - simp [step, received, advanced] - have simulation := - advance_simulates config received false output advanced - rw [same] at simulation - exact simulation - -private theorem receive_iamopen_simulates - (config : Config) - (before : NodeState) - (source : Location) - (validation : Validation) : - LegacyWeakStep - (projectPhase before) - (projectPhase - (step config before (.receiveIAmOpen source validation)).state) := by - cases validation with - | rejected => - simp [step, rejected] - exact .refl - | accepted => - cases phase : before.phase <;> - simp [step, phase, advance, rejected, projectPhase, - advanceTimeoutLane] - all_goals - first - | exact .single .gossipToJoin - | exact .single .votingToJoin - | exact .refl - -theorem canonical_step_simulates - (config : Config) - (before : NodeState) - (event : Event) : - LegacyWeakStep - (projectPhase before) - (projectPhase (step config before event).state) := by - cases event with - | receiveGossip source txid validation => - exact receive_gossip_simulates config before source txid validation - | receiveVote source validation => - exact receive_vote_simulates config before source validation - | receiveIAmOpen source validation => - exact receive_iamopen_simulates config before source validation - | timeout => - cases advanced : advance config before true with - | none => - simp [step, advanced, rejected] - exact .refl - | some output => - simp [step, advanced] - exact advance_simulates config before true output advanced - | retry => - exact .refl - -theorem compatibility_step_simulates - {config : Config} - {before after : NodeState} - {event : Event} - (compatible : CompatibilityStep config before event after) : - LegacyWeakStep (projectPhase before) (projectPhase after) := by - rw [compatible.canonical] - exact canonical_step_simulates config before event - -theorem retryCompatibility - (config : Config) - (state : NodeState) : - CompatibilityStep config state .retry state := { - canonical := rfl -} - -theorem voteQuorumCompatibility - (config : Config) - (before : NodeState) - (source : Location) : - CompatibilityStep config before - (.receiveVote source .accepted) - (step config before (.receiveVote source .accepted)).state := { - canonical := rfl -} - -theorem quorum_phase_step_is_weak - (before after : NodeState) - (beforePhase : before.phase = .voting) - (afterPhase : after.phase = .opening) - (kind : after.openKind = some .quorum) : - LegacyWeakStep (projectPhase before) (projectPhase after) := by - simp [projectPhase, beforePhase, afterPhase, kind] - exact .single .quorumOpen - -theorem opening_to_open_is_stuttering - (before after : NodeState) - (beforePhase : before.phase = .opening) - (afterPhase : after.phase = .open) - (kind : after.openKind = before.openKind) : - LegacyWeakStep (projectPhase before) (projectPhase after) := by - simp [projectPhase, beforePhase, afterPhase, kind] - exact .refl - -inductive CompatibilityTrace - (config : Config) : - NodeState -> - List Event -> - NodeState -> - Prop where - | nil (state) : CompatibilityTrace config state [] state - | cons - (first middle last event rest) - (head : CompatibilityStep config first event middle) - (tail : CompatibilityTrace config middle rest last) : - CompatibilityTrace config first (event :: rest) last - -theorem compatibility_trace_simulates - {config : Config} - {first last : NodeState} - {events : List Event} - (compatible : CompatibilityTrace config first events last) : - LegacyWeakStep (projectPhase first) (projectPhase last) := by - induction compatible with - | nil state => exact .refl - | cons first middle last event rest head tail induction => - exact Relation.ReflTransGen.trans - (compatibility_step_simulates head) induction - -theorem initial_phase_correspondence : - projectPhase (initialNode "node0") = DisasterRecoveryMigration.Legacy.Phase.vote := by - rfl - -theorem three_node_initial_correspondence : - let config : Config := { - instanceId := "compat" - expectedLocations := ["0", "1", "2"] - } - ((initialSystem config).nodes.map - (fun entry => projectPhase entry.2) == - (DisasterRecoveryMigration.Legacy.initialState 3).actors.toList.map - (fun actor => actor.nextStep)) = true := by - rfl - -theorem odd_quorum_matches_legacy - (nodes half : Nat) - (odd : nodes = 2 * half + 1) : - nodes / 2 + 1 = (nodes + 1) / 2 := by - subst nodes - simp [Nat.add_div] - -theorem even_quorum_exceeds_legacy_by_one - (nodes half : Nat) - (even : nodes = 2 * half) : - nodes / 2 + 1 = (nodes + 1) / 2 + 1 := by - subst nodes - simp [Nat.add_div] - -def canonicalReachedOpen (state : NodeState) : Prop := - state.phase = .opening \/ state.phase = .open - -def projectedReachedOpen (state : NodeState) : Prop := - match projectPhase state with - | .open _ => True - | _ => False - -theorem reached_open_is_preserved - (state : NodeState) : - canonicalReachedOpen state <-> projectedReachedOpen state := by - cases phase : state.phase <;> - cases kind : state.openKind <;> - simp [canonicalReachedOpen, projectedReachedOpen, projectPhase, phase, kind] - all_goals - rename_i value - cases value <;> - simp - -theorem quorum_kind_projects_to_non_timeout_open - (state : NodeState) - (phase : state.phase = .opening \/ state.phase = .open) - (kind : state.openKind = some .quorum) : - projectPhase state = .open false := by - cases phase with - | inl opening => - cases state - simp_all [projectPhase] - | inr opened => - cases state - simp_all [projectPhase] - -theorem single_node_full_initial_models_differ : - projectPhase (initialNode "0") != - (DisasterRecoveryMigration.Legacy.initialState 1).actors[0]!.nextStep := by - decide - -end DisasterRecoveryMigration.Refinement \ No newline at end of file diff --git a/lean/disaster-recovery-migration/ExportMain.lean b/lean/disaster-recovery-migration/ExportMain.lean deleted file mode 100644 index f7500f6a967c..000000000000 --- a/lean/disaster-recovery-migration/ExportMain.lean +++ /dev/null @@ -1,24 +0,0 @@ -import DisasterRecoveryMigration.Legacy.Checker - -open DisasterRecoveryMigration.Legacy - -private def usage : String := - "usage: migration-exporter [--nodes N]" - -private def parseNodes : List String -> Except String Nat - | [] => pure 3 - | ["--nodes", value] => - match value.toNat? with - | some n => if n > 0 then pure n else throw "--nodes must be positive" - | none => throw s!"invalid node count: {value}" - | _ => throw usage - -def main (args : List String) : IO UInt32 := do - match parseNodes args with - | .error message => - IO.eprintln message - pure 2 - | .ok n => - let graph <- enumerate n - exportGraph n graph - pure 0 diff --git a/lean/disaster-recovery-migration/Main.lean b/lean/disaster-recovery-migration/Main.lean deleted file mode 100644 index c943598da3bc..000000000000 --- a/lean/disaster-recovery-migration/Main.lean +++ /dev/null @@ -1,23 +0,0 @@ -import DisasterRecoveryMigration.Legacy.Checker - -open DisasterRecoveryMigration.Legacy - -private def usage : String := - "usage: migration-model-checker [--nodes N]" - -private def parseNodes : List String -> Except String Nat - | [] => pure 3 - | ["--nodes", value] => - match value.toNat? with - | some n => if n > 0 then pure n else throw "--nodes must be positive" - | none => throw s!"invalid node count: {value}" - | _ => throw usage - -def main (args : List String) : IO UInt32 := do - match parseNodes args with - | .error message => - IO.eprintln message - pure 2 - | .ok n => - let graph <- enumerate n - if <- checkGraph n graph then pure 0 else pure 1 diff --git a/lean/disaster-recovery-migration/README.md b/lean/disaster-recovery-migration/README.md deleted file mode 100644 index 3ea813d5400b..000000000000 --- a/lean/disaster-recovery-migration/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Temporary disaster recovery migration evidence - -This package is the temporary PR 2 evidence layer for migrating the legacy -Rust/Stateright disaster recovery model to Lean. It depends locally on the -canonical package in `../disaster-recovery`; it does not modify or duplicate -that package. This directory and the shared Lean workflow's migration-evidence -job are intended to be deleted by PR 3 once the evidence has served its -purpose. - -## Scope - -There are two distinct and deliberately weaker claims: - -1. The executable model in `DisasterRecoveryMigration.Legacy` is an exact - Lean mirror of the Rust/Stateright model in `tla/disaster-recovery`. - `compare.py` establishes exhaustive bounded equivalence for one, two, and - three nodes. -2. `DisasterRecoveryMigration.Refinement` relates the canonical C++-aligned - Lean model to the legacy Lean mirror only at the protocol-phase level. - -The bounded comparison is not a theorem about arbitrary node counts or a -formal semantics for Rust or Stateright. The phase refinement is not a full -bisimulation, data refinement, or proof that the canonical model is identical -to the Rust model. - -## Exact bounded equivalence - -Both exporters emit the stable `ccf-legacy-dr-graph-v1` format. State IDs are -assigned after sorting normalized state keys, independently of traversal -order. For each requested node count, `compare.py` checks: - -- the normalized initial state; -- every normalized reachable state in both directions; -- every labeled edge, including source and destination, in both directions; -- all nine registered predicate valuations for every reachable state; and -- the expected complete state and edge counts below. - -| Nodes | Reachable states | Labeled edges | Predicate values per state | -| ----: | ---------------: | ------------: | -------------------------: | -| 1 | 1 | 0 | 9 | -| 2 | 54 | 95 | 9 | -| 3 | 105,558 | 552,282 | 9 | - -The comparator fails on a difference from either exporter and reports a -shortest path to a representative state or edge mismatch. - -The mirror intentionally retains the legacy semantics, including message -multiplicity, unordered delivery, timer behavior, no-op suppression, immediate -multi-phase advancement, and the existing predicate definitions and names. -Differences in the canonical model are not backported into this oracle. - -## Canonical phase refinement and limitations - -`DisasterRecoveryMigration.Refinement` imports the canonical -`DisasterRecovery.Protocol.Model` through the local Lake dependency and -projects canonical phases as follows: - -- Gossiping maps to legacy Vote. -- Voting maps to legacy OpenJoin. -- canonical Opening and Open collapse to legacy Open, retaining quorum versus - failover as the legacy timeout flag. -- Joining maps to legacy Join. - -`canonical_step_simulates` proves that each canonical local step projects to a -reflexive-transitive legacy phase step. The file also proves finite compatible -trace simulation, collapsed-Open preservation, quorum-kind projection, and -Opening-to-Open stuttering. - -This phase-only result does not relate gossip sets, votes, timeout-lane state, -network state, transaction persistence, or all nine legacy predicates. It -does not establish a global scheduler correspondence or preserve the legacy -liveness expectations. - -Two intentional model differences are explicit: - -- The canonical quorum is the strict majority `n / 2 + 1`; the legacy quorum - is `(n + 1) / 2`. They agree for odd node counts, while for even node counts - the canonical threshold is one larger. -- With one node, the legacy full initial state opens immediately without a - timeout. The canonical initial node remains in Gossiping, whose projected - phase is Vote. `single_node_full_initial_models_differ` proves this mismatch. - -## Files - -| File | Purpose | -| ----------------------------------------------- | --------------------------------------------- | -| `DisasterRecoveryMigration/Legacy/Model.lean` | Exact executable legacy semantics | -| `DisasterRecoveryMigration/Legacy/Checker.lean` | BFS model checker and canonical graph encoder | -| `Main.lean` | Legacy model-checker CLI | -| `ExportMain.lean` | Separate Lean graph-exporter CLI | -| `Tests.lean` | Focused legacy semantic checks | -| `DisasterRecoveryMigration/Refinement.lean` | Canonical-to-legacy phase refinement | -| `compare.py` | Bidirectional exhaustive Rust/Lean comparison | - -## Validation - -Run from this directory: - -```console -lake exe cache get -lake exe mk_all --check --lib DisasterRecoveryMigration -lake build --wfail -lake lint -lake exe migration-semantic-checks -lake exe migration-model-checker --nodes 3 -python3 compare.py --nodes 1 2 3 -``` - -The canonical package's own axiom-audit configuration remains authoritative -for all canonical declarations and is run by the canonical job in the shared -Lean workflow. The migration Lake package pins Lean 4.33.1, transitively -resolves Mathlib v4.33.1, treats warnings as errors, verifies complete library -coverage with `mk_all --check`, and audits transitive axioms with `lake lint`. diff --git a/lean/disaster-recovery-migration/Tests.lean b/lean/disaster-recovery-migration/Tests.lean deleted file mode 100644 index 1555b42e0972..000000000000 --- a/lean/disaster-recovery-migration/Tests.lean +++ /dev/null @@ -1,72 +0,0 @@ -import DisasterRecoveryMigration.Legacy.Model - -open DisasterRecoveryMigration.Legacy - -private def expect (condition : Bool) (message : String) : IO Unit := - unless condition do throw (IO.userError message) - -private def deliverByKey (n : Nat) (state : GlobalState) (key : String) : Option GlobalState := do - let action <- (actions state).find? (fun action => actionKey action == key) - nextState n state action - -def main : IO UInt32 := do - let single := initialState 1 - expect (single.actors[0]!.nextStep == .open false) - "single node did not open immediately without timeout" - - let initial3 := initialState 3 - let timed <- match nextState 3 initial3 (.timeout 0) with - | some state => pure state - | none => throw (IO.userError "node 0 timeout was suppressed") - expect (timed.actors[0]!.nextStep == .open true) - "timeout did not drive vote and open-join closure to timeout-open" - - let opened := timed.actors[0]! - let lateGossip : Gossip := { src := 2, txid := 2 } - let frozen <- match onMessage 3 0 opened (.gossip lateGossip) with - | some result => pure result - | none => throw (IO.userError "message callback was unexpectedly suppressed") - expect (frozen.1.gossips == opened.gossips) - "gossip collection changed after the vote was submitted" - - let joinActor : ActorState := { - nextStep := .openJoin - gossips := [{ src := 1, txid := 1 }] - votes := [] - submittedVote := none - txid := 1 - } - let joined <- match onMessage 3 1 joinActor (.iAmOpen 0) with - | some result => pure result - | none => throw (IO.userError "IAmOpen was suppressed") - expect (joined.1.nextStep == .join) "IAmOpen did not cause Join" - - let firstOrder <- match deliverByKey 3 initial3 "deliver(1,0,g(1,1))" with - | some state => pure state - | none => throw (IO.userError "first unordered delivery failed") - let firstOrder <- match deliverByKey 3 firstOrder "deliver(2,0,g(2,2))" with - | some state => pure state - | none => throw (IO.userError "second unordered delivery failed") - let secondOrder <- match deliverByKey 3 initial3 "deliver(2,0,g(2,2))" with - | some state => pure state - | none => throw (IO.userError "reverse first unordered delivery failed") - let secondOrder <- match deliverByKey 3 secondOrder "deliver(1,0,g(1,1))" with - | some state => pure state - | none => throw (IO.userError "reverse second unordered delivery failed") - expect (firstOrder == secondOrder) "unordered deliveries produced different states" - - let duplicate : Envelope := { src := 1, dst := 0, msg := .gossip { src := 1, txid := 1 } } - let duplicated := { initial3 with network := duplicate :: duplicate :: initial3.network } - let once <- match nextState 3 duplicated (.deliver duplicate) with - | some state => pure state - | none => throw (IO.userError "first duplicate delivery was suppressed") - expect (once.network.count duplicate + 1 == duplicated.network.count duplicate) - "delivery did not remove exactly one duplicate" - let twice <- match nextState 3 once (.deliver duplicate) with - | some state => pure state - | none => throw (IO.userError "second duplicate delivery was suppressed") - expect (twice.network.count duplicate + 1 == once.network.count duplicate) - "second delivery did not remove exactly one duplicate" - - IO.println "all Lean semantic checks passed" - pure 0 diff --git a/lean/disaster-recovery-migration/compare.py b/lean/disaster-recovery-migration/compare.py deleted file mode 100755 index 005d09c1df5a..000000000000 --- a/lean/disaster-recovery-migration/compare.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the Apache 2.0 License. - -import argparse -import filecmp -import subprocess -import sys -import tempfile -from collections import defaultdict, deque -from dataclasses import dataclass -from pathlib import Path - -FORMAT = "ccf-legacy-dr-graph-v1" -PROPERTY_NAMES = ( - "Unanimous votes => no chance of a fork", - "Open", - "Majority votes => no fork", - "No open with timeout, no fork", - "Deadlock", - "Persist committed txs", - "Open is possible", - "Unsafe open with timeout", - "Majority vote still opens without timeout", -) -EXPECTED_COUNTS = { - 1: (1, 0), - 2: (54, 95), - 3: (105558, 552282), -} - - -@dataclass(frozen=True) -class Summary: - initial_key: str - states: int - edges: int - - -@dataclass -class Graph: - initial: str - valuations: dict[str, str] - edges: set[tuple[str, str, str]] - - -def run(command: list[str], cwd: Path, output: Path | None = None) -> None: - print(f"+ (cd {cwd} && {' '.join(command)})", flush=True) - if output is None: - result = subprocess.run( - command, cwd=cwd, text=True, capture_output=True, check=False - ) - else: - with output.open("w", encoding="ascii", newline="") as stream: - result = subprocess.run( - command, - cwd=cwd, - text=True, - stdout=stream, - stderr=subprocess.PIPE, - check=False, - ) - if result.returncode != 0: - if result.stderr: - print(result.stderr, file=sys.stderr, end="") - raise RuntimeError(f"command exited with status {result.returncode}") - - -def validate(path: Path, expected_nodes: int) -> Summary: - ids_to_keys: list[str] = [] - initial_id: int | None = None - edge_count = 0 - previous_edge: tuple[int, str, int] | None = None - section = "header" - - with path.open(encoding="ascii") as stream: - for line_number, raw_line in enumerate(stream, 1): - fields = raw_line.rstrip("\n").split("\t") - if fields == ["format", FORMAT] and line_number == 1: - continue - if fields == ["nodes", str(expected_nodes)] and line_number == 2: - continue - if len(fields) == 2 and fields[0] == "init" and line_number == 3: - initial_id = int(fields[1]) - section = "states" - continue - if len(fields) == 4 and fields[0] == "state" and section == "states": - state_id = int(fields[1]) - if state_id != len(ids_to_keys): - raise ValueError( - f"{path}:{line_number}: expected dense state id " - f"{len(ids_to_keys)}, found {state_id}" - ) - if ids_to_keys and fields[2] <= ids_to_keys[-1]: - raise ValueError( - f"{path}:{line_number}: state keys are unsorted or duplicated" - ) - bits = fields[3] - if len(bits) != len(PROPERTY_NAMES) or set(bits) - {"0", "1"}: - raise ValueError( - f"{path}:{line_number}: invalid property bitstring" - ) - ids_to_keys.append(fields[2]) - continue - if len(fields) == 4 and fields[0] == "edge": - section = "edges" - edge = (int(fields[1]), fields[2], int(fields[3])) - if edge[0] >= len(ids_to_keys) or edge[2] >= len(ids_to_keys): - raise ValueError( - f"{path}:{line_number}: edge references unknown state" - ) - if previous_edge is not None and edge <= previous_edge: - raise ValueError( - f"{path}:{line_number}: edges are unsorted or duplicated" - ) - previous_edge = edge - edge_count += 1 - continue - raise ValueError( - f"{path}:{line_number}: invalid record: {raw_line.rstrip()}" - ) - - if initial_id is None or initial_id >= len(ids_to_keys): - raise ValueError(f"{path}: invalid or missing initial state") - return Summary(ids_to_keys[initial_id], len(ids_to_keys), edge_count) - - -def load(path: Path) -> Graph: - ids_to_keys: list[str] = [] - valuations: dict[str, str] = {} - raw_edges: list[tuple[int, str, int]] = [] - initial_id = -1 - with path.open(encoding="ascii") as stream: - for raw_line in stream: - fields = raw_line.rstrip("\n").split("\t") - if fields[0] == "init": - initial_id = int(fields[1]) - elif fields[0] == "state": - state_id = int(fields[1]) - key = fields[2] - if state_id != len(ids_to_keys): - raise ValueError(f"{path}: non-dense state IDs") - ids_to_keys.append(key) - valuations[key] = fields[3] - elif fields[0] == "edge": - raw_edges.append((int(fields[1]), fields[2], int(fields[3]))) - edges = { - (ids_to_keys[src], action, ids_to_keys[dst]) for src, action, dst in raw_edges - } - return Graph(ids_to_keys[initial_id], valuations, edges) - - -def shortest_paths(graph: Graph) -> tuple[dict[str, int], dict[str, tuple[str, str]]]: - adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list) - for src, action, dst in graph.edges: - adjacency[src].append((action, dst)) - for outgoing in adjacency.values(): - outgoing.sort() - - distance = {graph.initial: 0} - parent: dict[str, tuple[str, str]] = {} - pending = deque([graph.initial]) - while pending: - src = pending.popleft() - for action, dst in adjacency[src]: - if dst not in distance: - distance[dst] = distance[src] + 1 - parent[dst] = (src, action) - pending.append(dst) - return distance, parent - - -def describe_path( - graph: Graph, target: str, cached: tuple[dict[str, int], dict[str, tuple[str, str]]] -) -> str: - distance, parent = cached - if target not in distance: - return f"unreachable target key {target}" - actions: list[str] = [] - cursor = target - while cursor != graph.initial: - cursor, action = parent[cursor] - actions.append(action) - actions.reverse() - rendered = "\n".join( - f" {index}. {action}" for index, action in enumerate(actions, 1) - ) - return f"target: {target}\n{rendered or ' '}" - - -def mismatch(rust: Graph, lean: Graph) -> str: - if rust.initial != lean.initial: - return f"initial state mismatch\nRust: {rust.initial}\nLean: {lean.initial}" - - rust_paths = lean_paths = None - rust_states = set(rust.valuations) - lean_states = set(lean.valuations) - if rust_states != lean_states: - rust_only = rust_states - lean_states - lean_only = lean_states - rust_states - candidates: list[tuple[int, str, str, Graph]] = [] - if rust_only: - rust_paths = shortest_paths(rust) - state = min( - rust_only, key=lambda key: (rust_paths[0].get(key, sys.maxsize), key) - ) - candidates.append( - (rust_paths[0].get(state, sys.maxsize), "Rust-only", state, rust) - ) - if lean_only: - lean_paths = shortest_paths(lean) - state = min( - lean_only, key=lambda key: (lean_paths[0].get(key, sys.maxsize), key) - ) - candidates.append( - (lean_paths[0].get(state, sys.maxsize), "Lean-only", state, lean) - ) - _, side, state, graph = min(candidates) - paths = rust_paths if graph is rust else lean_paths - return ( - f"reachable state mismatch ({len(rust_only)} Rust-only, " - f"{len(lean_only)} Lean-only); shortest is {side}\n" - f"{describe_path(graph, state, paths)}" - ) - - rust_only_edges = rust.edges - lean.edges - lean_only_edges = lean.edges - rust.edges - if rust_only_edges or lean_only_edges: - candidates = [] - if rust_only_edges: - rust_paths = shortest_paths(rust) - edge = min( - rust_only_edges, - key=lambda value: (rust_paths[0].get(value[0], sys.maxsize), value), - ) - candidates.append( - ( - rust_paths[0].get(edge[0], sys.maxsize), - "Rust-only", - edge, - rust, - ) - ) - if lean_only_edges: - lean_paths = shortest_paths(lean) - edge = min( - lean_only_edges, - key=lambda value: (lean_paths[0].get(value[0], sys.maxsize), value), - ) - candidates.append( - ( - lean_paths[0].get(edge[0], sys.maxsize), - "Lean-only", - edge, - lean, - ) - ) - _, side, (src, action, dst), graph = min(candidates) - paths = rust_paths if graph is rust else lean_paths - return ( - f"labeled edge mismatch ({len(rust_only_edges)} Rust-only, " - f"{len(lean_only_edges)} Lean-only); shortest source is {side}\n" - f"{describe_path(graph, src, paths)}\n" - f"missing edge action: {action}\ndestination: {dst}" - ) - - differing = { - key for key in rust_states if rust.valuations[key] != lean.valuations[key] - } - if differing: - rust_paths = shortest_paths(rust) - state = min( - differing, key=lambda key: (rust_paths[0].get(key, sys.maxsize), key) - ) - rust_bits = rust.valuations[state] - lean_bits = lean.valuations[state] - details = [ - f" {index + 1}. {name}: Rust={rust_bits[index]} Lean={lean_bits[index]}" - for index, name in enumerate(PROPERTY_NAMES) - if rust_bits[index] != lean_bits[index] - ] - return ( - f"property valuation mismatch in {len(differing)} states\n" - f"{describe_path(rust, state, rust_paths)}\n" + "\n".join(details) - ) - - return "canonical files differ despite identical graph content" - - -def compare(nodes: int, lean_dir: Path, rust_dir: Path, temporary: Path) -> Summary: - rust_path = temporary / f"rust-{nodes}.tsv" - lean_path = temporary / f"lean-{nodes}.tsv" - run( - [ - "cargo", - "run", - "--quiet", - "--", - "export", - "--nodes", - str(nodes), - "-o", - str(rust_path), - ], - rust_dir, - ) - run( - ["lake", "exe", "migration-exporter", "--nodes", str(nodes)], - lean_dir, - lean_path, - ) - rust_summary = validate(rust_path, nodes) - lean_summary = validate(lean_path, nodes) - if rust_summary != lean_summary or not filecmp.cmp( - rust_path, lean_path, shallow=False - ): - raise AssertionError(mismatch(load(rust_path), load(lean_path))) - expected = EXPECTED_COUNTS.get(nodes) - if expected is not None and (rust_summary.states, rust_summary.edges) != expected: - raise AssertionError( - f"n={nodes}: expected {expected[0]} states/{expected[1]} edges, " - f"found {rust_summary.states}/{rust_summary.edges}" - ) - return rust_summary - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Exhaustively compare Rust/Stateright and Lean legacy DR graphs" - ) - parser.add_argument("--nodes", type=int, nargs="+", default=[1, 2, 3]) - args = parser.parse_args() - if any(nodes < 1 for nodes in args.nodes): - parser.error("node counts must be positive") - - lean_dir = Path(__file__).resolve().parent - rust_dir = lean_dir.parents[1] / "tla" / "disaster-recovery" - try: - scratch = lean_dir / ".lake" - scratch.mkdir(exist_ok=True) - with tempfile.TemporaryDirectory( - prefix="ccf-legacy-dr-", dir=scratch - ) as directory: - for nodes in args.nodes: - summary = compare(nodes, lean_dir, rust_dir, Path(directory)) - print( - f"n={nodes}: equivalent initial state, {summary.states} states, " - f"{summary.edges} labeled edges compared in both directions, " - f"{len(PROPERTY_NAMES)} valuations/state" - ) - except (AssertionError, OSError, RuntimeError, ValueError) as error: - print(f"equivalence failed: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/lean/disaster-recovery-migration/lake-manifest.json b/lean/disaster-recovery-migration/lake-manifest.json deleted file mode 100644 index 2fcc2ce77743..000000000000 --- a/lean/disaster-recovery-migration/lake-manifest.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "version": "1.2.0", - "packagesDir": ".lake/packages", - "packages": [ - { - "type": "path", - "scope": "", - "name": "disaster_recovery", - "manifestFile": "lake-manifest.json", - "inherited": false, - "dir": "../disaster-recovery", - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/axiom-audit.git", - "type": "git", - "subDir": null, - "scope": "", - "rev": "46024e005996495c65ef609368e11ab39c4222e3", - "name": "axiomAudit", - "manifestFile": "lake-manifest.json", - "inputRev": "46024e005996495c65ef609368e11ab39c4222e3", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/mathlib4.git", - "type": "git", - "subDir": null, - "scope": "", - "rev": "0df444a360eaa60ab8c11dca51a86af692955474", - "name": "mathlib", - "manifestFile": "lake-manifest.json", - "inputRev": "v4.33.1", - "inherited": true, - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover-community/plausible", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "b7eb3304aeae834b12dda98993a37f6a41f6f0bb", - "name": "plausible", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/LeanSearchClient", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "5f4d51b81cbd3f6b32b156bfad9056621a040404", - "name": "LeanSearchClient", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/import-graph", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "16f02aa7642864af59f1ff0e384a015994db9118", - "name": "importGraph", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/ProofWidgets4", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "4be2e3d5087eeb272cf5a8853b8f9dd025ef5957", - "name": "proofwidgets", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean" - }, - { - "url": "https://github.com/leanprover-community/aesop", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "3448c0bcc5ce01b2d1546e483ec3620e32df3d0e", - "name": "aesop", - "manifestFile": "lake-manifest.json", - "inputRev": "master", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/quote4", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "92c15be17b7caf78c2ad767ec40f89052d908d81", - "name": "Qq", - "manifestFile": "lake-manifest.json", - "inputRev": "master", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover-community/batteries", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "4488d40d070b9700d4d5a6aa342f0d40c31b2a2d", - "name": "batteries", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml" - }, - { - "url": "https://github.com/leanprover/lean4-cli", - "type": "git", - "subDir": null, - "scope": "leanprover", - "rev": "6130a47896ce867c6a4a55373441e59e565bad0f", - "name": "Cli", - "manifestFile": "lake-manifest.json", - "inputRev": "v4.33.0", - "inherited": true, - "configFile": "lakefile.toml" - } - ], - "name": "disaster_recovery_migration", - "lakeDir": ".lake", - "fixedToolchain": false -} diff --git a/lean/disaster-recovery-migration/lakefile.toml b/lean/disaster-recovery-migration/lakefile.toml deleted file mode 100644 index 2096426c753f..000000000000 --- a/lean/disaster-recovery-migration/lakefile.toml +++ /dev/null @@ -1,31 +0,0 @@ -name = "disaster_recovery_migration" -version = "0.1.0" -moreLeanArgs = ["-DwarningAsError=true"] -# Quote the hyphenated executable name for Lean's name parser. -lintDriver = "axiomAudit/\u00abaxiom-audit\u00bb" -lintDriverArgs = ["--root", "DisasterRecoveryMigration"] -defaultTargets = [ - "DisasterRecoveryMigration", - "migration-model-checker", - "migration-semantic-checks", - "migration-exporter", -] - -[[require]] -name = "disaster_recovery" -path = "../disaster-recovery" - -[[lean_lib]] -name = "DisasterRecoveryMigration" - -[[lean_exe]] -name = "migration-model-checker" -root = "Main" - -[[lean_exe]] -name = "migration-semantic-checks" -root = "Tests" - -[[lean_exe]] -name = "migration-exporter" -root = "ExportMain" diff --git a/lean/disaster-recovery-migration/lean-toolchain b/lean/disaster-recovery-migration/lean-toolchain deleted file mode 100644 index a8afa7d1b02d..000000000000 --- a/lean/disaster-recovery-migration/lean-toolchain +++ /dev/null @@ -1 +0,0 @@ -leanprover/lean4:v4.33.1 diff --git a/tla/disaster-recovery/.gitignore b/tla/disaster-recovery/.gitignore deleted file mode 100644 index eb5a316cbd19..000000000000 --- a/tla/disaster-recovery/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target diff --git a/tla/disaster-recovery/Cargo.lock b/tla/disaster-recovery/Cargo.lock deleted file mode 100644 index 9666614b5e3e..000000000000 --- a/tla/disaster-recovery/Cargo.lock +++ /dev/null @@ -1,592 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "anstream" -version = "0.6.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "ccf-selfhealingopen" -version = "0.0.0" -dependencies = [ - "clap", - "stateright", -] - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "choice" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3b71fc821deaf602a933ada5c845d088156d0cdf2ebf43ede390afe93466553" - -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - -[[package]] -name = "clap" -version = "4.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "id-set" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9633fadf6346456cf8531119ba4838bc6d82ac4ce84d9852126dd2aa34d49264" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "libc" -version = "0.2.173" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.140" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stateright" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1157f21b11916f90fe1f2ac9a8d0e09a8813b28701584141060f414eedf6ba" -dependencies = [ - "ahash", - "choice", - "crossbeam-utils", - "dashmap", - "id-set", - "log", - "nohash-hasher", - "parking_lot", - "rand", - "serde", - "serde_json", - "tiny_http", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] - -[[package]] -name = "zerocopy" -version = "0.8.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/tla/disaster-recovery/Cargo.toml b/tla/disaster-recovery/Cargo.toml deleted file mode 100644 index 92950edbfb19..000000000000 --- a/tla/disaster-recovery/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "ccf-selfhealingopen" -version = "0.0.0" - -[dependencies] -clap = { version = "4.5.38", features = ["derive"] } -stateright = "0.31.0" diff --git a/tla/disaster-recovery/Readme.md b/tla/disaster-recovery/Readme.md deleted file mode 100644 index d13a98b9ac84..000000000000 --- a/tla/disaster-recovery/Readme.md +++ /dev/null @@ -1,59 +0,0 @@ -# Self-healing-open specification in [stateright](https://github.com/stateright/stateright) - -The properties are specified in [main.rs](./src/main.rs), while the model is specified in [model.rs](./src/model.rs). - -Due to stateright being executable, there is little syntactic sugar, and so there is quite a bit of boilerplate. -The functional parts of the specification are in `advance_step`, `on_start`, `on_timeout` and `on_msg`. - -The specification can be checked from the command line via `cargo run check`. - -However, a more useful UX is via the web-view which is hosted locally via `cargo run serve`. -This allows you to explore the specification actions interactively, and the checker can be exhaustively run using the `Run to completion` button, which should find several useful examples of states where the network is opened, and where a deadlock is reached. - -## Exporting the state graph - -`cargo run --quiet -- export --nodes [-o ]` exhaustively enumerates the reachable -state graph (via the public `stateright::Model` interface, i.e. `init_states`/`next_steps`) -and writes it to `` (or stdout) in the shared `ccf-legacy-dr-graph-v1` TSV contract, so -it can be diffed against an independent re-implementation of the same model (e.g. in Python -or Lean). The encoder (`src/export.rs`) never uses `Debug` formatting, so output is insulated -from field order, hash-set iteration order, and library-version changes. Full grammar and -design notes are documented in the module doc comment at the top of `src/export.rs`; summary: - -```text -format ccf-legacy-dr-graph-v1 -nodes -init -state (one per reachable state, ascending ) -edge (one per reachable transition, sorted) -``` - -- `` is a dense integer (`0..N_STATES`) assigned by sorting every reachable state's - `` lexicographically -- _not_ BFS/discovery order -- so numbering is a pure - function of the reachable state set. Edges reference states only by `` (not by - repeating ``), since e.g. `n=3` already has 105,558 states / 552,282 edges and - repeating full state keys per edge does not scale. `edge` records are sorted by the tuple - `(, text, )` -- numeric on the ids, lexicographic on the action -- - and de-duplicated. -- `` is 9 chars of `1`/`0`, one per predicate registered via - `ActorModel::property` (`model.properties`), in registration order -- the exact same `fn` - pointers used by `check`/`serve`, so the export can never drift from their semantics. -- `` is `S([ACTORS],[TIMERS],[ENVELOPES])`: `ACTORS` are semicolon-separated - `s(PHASE,[GOSSIPS],[VOTES],SUBMITTED,txid)` records (`PHASE` in `vote`/`openjoin`/`open0`/ - `open1`/`join`), `TIMERS` are the ids of actors with an active election timeout, and - `ENVELOPES` are `e(src,dst,msg)#count` in flight. `history`/`random_choices`/ - `actor_storages` (always the unit value `()` for this model) and `crashed` (always all - `false`, since `max_crashes` is never set above `0`) are omitted, as they carry no - information here. -- `` is `deliver(src,dst,msg)` or `timeout(id,election)`. -- Set elements (`GOSSIPS`, `VOTES`) and `ENVELOPES` are ordered using the real Rust - `#[derive(Ord)]` implementations of `GossipStruct`/`VoteStruct`/`Envelope` (not a - string sort). -- Per `Model::next_state`'s "`None` = no-op" contract, only actions where `next_state` - returns `Some` produce an `edge` line (`Model::next_steps`'s default implementation - already filters these out). - -`--nodes` is an alias for the existing `--n-nodes`/`-n` flag, and (being a global clap -argument) is accepted either before or after the subcommand, so existing invocations -(`cargo run -- --n-nodes 3 check`, `cargo run check`, `cargo run -- --n-nodes 3 serve`) keep -working unchanged alongside `cargo run --quiet -- export --nodes `. diff --git a/tla/disaster-recovery/src/export.rs b/tla/disaster-recovery/src/export.rs deleted file mode 100644 index e62e4702c8dc..000000000000 --- a/tla/disaster-recovery/src/export.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! Dependency-free canonical export of the reachable state graph. -//! -//! Implements the shared `ccf-legacy-dr-graph-v1` TSV contract: the model is -//! enumerated exhaustively using only the public `stateright::Model` -//! interface (`init_states`, `next_steps`, `within_boundary`), and every -//! state/action is serialized with an explicit hand-written grammar (never -//! `Debug`), so the output is stable across compiler/library versions and -//! diffable byte-for-byte against an independent re-implementation (e.g. -//! Python, Lean) of the same state machine. -//! -//! No new dependencies are introduced: only `stateright` (already a direct -//! dependency) and `std` are used. -//! -//! # Format -//! -//! ```text -//! format\tccf-legacy-dr-graph-v1 -//! nodes\t -//! init\t -//! state\t\t\t (one per reachable state) -//! edge\t\t\t (one per reachable transition) -//! ``` -//! -//! `` is a canonical, dense integer id (`0..N_STATES`) assigned by -//! sorting every reachable state's `` (see below) lexicographically -//! and numbering them in that order -- *not* BFS/discovery order -- so ids are -//! reproducible independent of traversal strategy. `state` records are -//! emitted in ascending `` order (equivalently, ascending `` -//! order). `edge` records are emitted sorted by the tuple -//! `(, text, )` (numeric on the ids, lexicographic on -//! the action text), and de-duplicated. Repeating the full `` in -//! every edge does not scale (e.g. `n=3` already has 105,558 states / 552,282 -//! edges), so edges reference states only by ``; a reader reconstructs the -//! `` for any `` via the `state` block. -//! -//! `` is exactly 9 characters of `1`/`0`, one per predicate -//! currently registered on the model via `ActorModel::property` -//! (`model.properties`), in registration order (liveness, then invariant, -//! then reachable properties -- *not* alphabetical). Each bit is the exact -//! existing `Property::condition` closure evaluated on that state, so the -//! export can never drift from `check`/`serve` behaviour, and preserves each -//! predicate's existing (sometimes misleadingly worded) name/meaning even -//! though names themselves are not repeated in the TSV output. -//! -//! Grammar for ``/`` tokens (no token contains whitespace): -//! -//! - gossip: `g(src,txid)` -//! - vote: `v(src,[GOSSIPS])` where `GOSSIPS` is a comma-separated gossip list -//! - msg: a gossip, a vote, or `o(id)` (`IAmOpen`) -//! - envelope: `e(src,dst,msg)` -//! - submitted vote: `none` or `some(dst,vote)` -//! - actor: `s(PHASE,[GOSSIPS],[VOTES],SUBMITTED,txid)` where `PHASE` is one -//! of `vote`, `openjoin`, `open0` (`Open { timeout: false }`), `open1` -//! (`Open { timeout: true }`), `join` -//! - global state: `S([ACTORS],[TIMERS],[ENVELOPES])` where `ACTORS` is -//! semicolon-separated (positional, by actor index), `TIMERS` is a -//! comma-separated list of actor ids with an active election timeout, and -//! `ENVELOPES` is a comma-separated list of `envelope#count` (count being -//! the in-flight multiplicity of that exact envelope) -//! - action: `deliver(src,dst,msg)` or `timeout(id,election)` -//! -//! `history` (`H = ()`), `random_choices` (`Node::Random = ()`), -//! `actor_storages` (`Node::Storage = ()`), and `crashed` (always all-`false`, -//! since `max_crashes` is never configured above `0`) are all omitted from -//! `S(...)`: for this model they are always constant/empty and carry no -//! information. -//! -//! Set elements (`GOSSIPS`, `VOTES`) are ordered using the actual Rust -//! `#[derive(Ord)]` implementation of `GossipStruct`/`VoteStruct` (not a -//! string sort), and `ENVELOPES` are ordered using `Envelope`'s derived -//! `Ord`. Per `Model::next_state`'s documented contract ("`None` indicates -//! the action does not change state"), only actions for which `next_state` -//! returns `Some` produce an edge; this is preserved by using -//! `Model::next_steps`, whose default implementation already filters out -//! `None` results. - -use crate::model::{GossipStruct, ModelCfg, Msg, NextStep, Node, State, Timer, VoteStruct}; -use stateright::actor::{ - ActorModel, ActorModelAction, ActorModelState, Envelope, Id, Network, Timers, -}; -use stateright::Model; -use std::collections::{HashMap, VecDeque}; -use std::io::{self, Write}; - -const PREDICATE_COUNT: usize = 9; - -fn fmt_id(id: Id) -> String { - usize::from(id).to_string() -} - -fn fmt_gossip(g: &GossipStruct) -> String { - format!("g({},{})", fmt_id(g.src), g.txid) -} - -/// Clones and sorts a gossip set using `GossipStruct`'s derived `Ord` -/// (compares `src` then `txid`), per the shared contract's "sort set -/// elements by Rust derived Ord". -fn sorted_gossips(set: &stateright::util::HashableHashSet) -> Vec { - let mut v: Vec = set.iter().cloned().collect(); - v.sort(); - v -} - -fn fmt_gossip_list(set: &stateright::util::HashableHashSet) -> String { - let items: Vec = sorted_gossips(set).iter().map(fmt_gossip).collect(); - format!("[{}]", items.join(",")) -} - -fn fmt_vote(v: &VoteStruct) -> String { - format!("v({},{})", fmt_id(v.src), fmt_gossip_list(&v.recv)) -} - -/// Clones and sorts a vote set using `VoteStruct`'s derived `Ord` (compares -/// `src` then `recv`). -fn sorted_votes(set: &stateright::util::HashableHashSet) -> Vec { - let mut v: Vec = set.iter().cloned().collect(); - v.sort(); - v -} - -fn fmt_vote_list(set: &stateright::util::HashableHashSet) -> String { - let items: Vec = sorted_votes(set).iter().map(fmt_vote).collect(); - format!("[{}]", items.join(",")) -} - -fn fmt_submitted(sv: &Option<(Id, VoteStruct)>) -> String { - match sv { - None => "none".to_string(), - Some((dst, vote)) => format!("some({},{})", fmt_id(*dst), fmt_vote(vote)), - } -} - -fn fmt_phase(n: &NextStep) -> &'static str { - match n { - NextStep::Vote => "vote", - NextStep::OpenJoin => "openjoin", - NextStep::Open { timeout: false } => "open0", - NextStep::Open { timeout: true } => "open1", - NextStep::Join => "join", - } -} - -fn fmt_actor(s: &State) -> String { - format!( - "s({},{},{},{},{})", - fmt_phase(&s.next_step), - fmt_gossip_list(&s.gossips), - fmt_vote_list(&s.votes), - fmt_submitted(&s.submitted_vote), - s.txid, - ) -} - -fn fmt_msg(m: &Msg) -> String { - match m { - Msg::Gossip(g) => fmt_gossip(g), - Msg::Vote(v) => fmt_vote(v), - Msg::IAmOpen(id) => format!("o({})", fmt_id(*id)), - } -} - -fn fmt_envelope(env: &Envelope) -> String { - format!( - "e({},{},{})", - fmt_id(env.src), - fmt_id(env.dst), - fmt_msg(&env.msg) - ) -} - -/// Tallies in-flight multiplicity per distinct envelope. `Network::iter_all` -/// yields one item per unit of multiplicity regardless of the underlying -/// `Network` variant (this model only ever uses -/// `new_unordered_nonduplicating`, whose internal representation already -/// tracks a count directly), so tallying via `iter_all` is variant-agnostic -/// and stays correct if the network configuration ever changes. -fn network_counts(network: &Network) -> Vec<(Envelope, usize)> { - let mut counts: HashMap, usize> = HashMap::new(); - for env in network.iter_all() { - *counts.entry(env.to_cloned_msg()).or_insert(0) += 1; - } - let mut v: Vec<(Envelope, usize)> = counts.into_iter().collect(); - // Envelope's derived Ord (src, dst, msg), per the shared contract. - v.sort_by(|a, b| a.0.cmp(&b.0)); - v -} - -fn fmt_network(network: &Network) -> String { - let items: Vec = network_counts(network) - .iter() - .map(|(env, count)| format!("{}#{}", fmt_envelope(env), count)) - .collect(); - format!("[{}]", items.join(",")) -} - -/// Comma-separated, ascending list of actor ids with an active election -/// timeout. `Timer` currently has a single variant, so presence alone is -/// significant (no timer-kind tag is emitted). -fn fmt_timers(timers_set: &[Timers]) -> String { - let mut ids: Vec = timers_set - .iter() - .enumerate() - .filter(|(_, t)| t.iter().next().is_some()) - .map(|(i, _)| i) - .collect(); - ids.sort_unstable(); - let items: Vec = ids.iter().map(|i| i.to_string()).collect(); - format!("[{}]", items.join(",")) -} - -/// Canonical `S(...)` encoding of a full `ActorModelState`. Used both -/// as the state field in `state` records and as the basis of the canonical -/// state id, so two independent implementations that compute the same -/// reachable state always produce the same key, regardless of traversal order. -pub fn fmt_state(state: &ActorModelState) -> String { - let actors: Vec = state.actor_states.iter().map(|s| fmt_actor(s)).collect(); - format!( - "S([{}],{},{})", - actors.join(";"), - fmt_timers(&state.timers_set), - fmt_network(&state.network), - ) -} - -/// Canonical encoding of an `ActorModelAction`. Only `Deliver` and `Timeout` -/// are part of the `ccf-legacy-dr-graph-v1` contract: this model never -/// produces `Drop` (`LossyNetwork::No`), `Crash`/`Recover` (`max_crashes == -/// 0`), or `SelectRandom` (no `Actor` ever issues a `ChooseRandom` command), -/// so encountering one is a bug (e.g. a future model config change) rather -/// than a case the contract needs to define. -pub fn fmt_action(action: &ActorModelAction) -> String { - match action { - ActorModelAction::Deliver { src, dst, msg } => { - format!( - "deliver({},{},{})", - fmt_id(*src), - fmt_id(*dst), - fmt_msg(msg) - ) - } - ActorModelAction::Timeout(id, Timer::ElectionTimeout) => { - format!("timeout({},election)", fmt_id(*id)) - } - _ => unreachable!( - "action variant is outside the ccf-legacy-dr-graph-v1 contract \ - (only Deliver/Timeout are ever produced by this model's configuration)" - ), - } -} - -/// The 9-character `1`/`0` bitstring for `state`, one bit per predicate -/// currently registered on `model` (`model.properties`) in registration -/// order -- i.e. exactly the same `fn` pointers used by `check`/`serve`, so -/// this can never drift from their semantics. -fn predicate_bitstring( - model: &ActorModel, - state: &ActorModelState, -) -> String { - model - .properties - .iter() - .map(|p| { - if (p.condition)(model, state) { - '1' - } else { - '0' - } - }) - .collect() -} - -/// Exhaustively enumerates the reachable state graph of `model` via the -/// public `stateright::Model` interface (`init_states`, `next_steps`, -/// `within_boundary`) and writes it to `out` as `ccf-legacy-dr-graph-v1`. -/// -/// States are discovered by BFS (for traversal only), but ``s are -/// assigned afterwards by sorting all discovered ``s -/// lexicographically -- so the numbering is a pure function of the reachable -/// state set, independent of traversal order. Edges reference states by -/// `` only, keeping output size linear in (states + edges) rather than -/// (edges * average state size). -pub fn export_graph( - model: &ActorModel, - out: &mut W, -) -> io::Result<()> { - assert_eq!( - model.properties.len(), - PREDICATE_COUNT, - "ccf-legacy-dr-graph-v1 requires exactly {PREDICATE_COUNT} registered model properties" - ); - - // Indexed by BFS discovery order (a "discovery id"); remapped to the - // canonical sorted-key id only once the full state set is known. - let mut visited: HashMap, usize> = HashMap::new(); - let mut keys: Vec = Vec::new(); - let mut bits: Vec = Vec::new(); - let mut frontier: VecDeque> = VecDeque::new(); - // (discovery src id, action text, discovery dst id) - let mut edges: Vec<(usize, String, usize)> = Vec::new(); - - let mut init_states = model.init_states(); - assert_eq!( - init_states.len(), - 1, - "ccf-legacy-dr-graph-v1 assumes a single deterministic init state" - ); - let init_state = init_states.remove(0); - assert!( - model.within_boundary(&init_state), - "ccf-legacy-dr-graph-v1 assumes the init state is within the model boundary" - ); - let init_discovery_id = keys.len(); - keys.push(fmt_state(&init_state)); - bits.push(predicate_bitstring(model, &init_state)); - visited.insert(init_state.clone(), init_discovery_id); - frontier.push_back(init_state); - - while let Some(s) = frontier.pop_front() { - let src_discovery_id = *visited - .get(&s) - .expect("every frontier state was inserted into `visited` before being queued"); - // `next_steps` (default `Model` trait method) already filters out - // actions for which `next_state` returns `None`, preserving the - // documented no-op-suppression contract. - for (action, ns) in model.next_steps(&s) { - if !model.within_boundary(&ns) { - continue; - } - let action_key = fmt_action(&action); - let dst_discovery_id = if let Some(&id) = visited.get(&ns) { - id - } else { - let id = keys.len(); - keys.push(fmt_state(&ns)); - bits.push(predicate_bitstring(model, &ns)); - visited.insert(ns.clone(), id); - frontier.push_back(ns); - id - }; - edges.push((src_discovery_id, action_key, dst_discovery_id)); - } - } - - // Canonical id assignment: number every discovered state by the - // lexicographic order of its ``, not by discovery order. - let mut order: Vec = (0..keys.len()).collect(); - order.sort_by(|&a, &b| keys[a].cmp(&keys[b])); - let mut canonical_id: Vec = vec![0; keys.len()]; - for (id, &discovery_id) in order.iter().enumerate() { - canonical_id[discovery_id] = id; - } - - // Remap edges to canonical ids, then sort by (SRC_ID, ACTION, DST_ID) -- - // numeric on the ids (real `usize` comparison, not string comparison), - // lexicographic on the action text -- and de-duplicate. - let mut canonical_edges: Vec<(usize, String, usize)> = edges - .into_iter() - .map(|(src, action, dst)| (canonical_id[src], action, canonical_id[dst])) - .collect(); - canonical_edges.sort(); - canonical_edges.dedup(); - - writeln!(out, "format\tccf-legacy-dr-graph-v1")?; - writeln!(out, "nodes\t{}", model.actors.len())?; - writeln!(out, "init\t{}", canonical_id[init_discovery_id])?; - for (id, &discovery_id) in order.iter().enumerate() { - writeln!( - out, - "state\t{}\t{}\t{}", - id, keys[discovery_id], bits[discovery_id] - )?; - } - for (src, action, dst) in &canonical_edges { - writeln!(out, "edge\t{src}\t{action}\t{dst}")?; - } - Ok(()) -} diff --git a/tla/disaster-recovery/src/main.rs b/tla/disaster-recovery/src/main.rs deleted file mode 100644 index 15bcef28f7ad..000000000000 --- a/tla/disaster-recovery/src/main.rs +++ /dev/null @@ -1,274 +0,0 @@ -extern crate clap; -extern crate stateright; -use clap::Parser; -mod export; -mod model; -use export::export_graph; -use model::{ModelCfg, Msg, NextStep, Node, State}; -use stateright::{actor::*, report::WriteReporter, util::HashableHashSet, Checker, Model}; -use std::sync::Arc; - -fn implies(a: bool, b: bool) -> bool { - !a || b -} - -fn reached_open(state: &ActorModelState) -> bool { - state - .actor_states - .iter() - .any(|actor_state: &Arc| matches!(actor_state.next_step, NextStep::Open { .. })) -} - -fn reached_open_timeout(state: &ActorModelState, expected_to_timeout: bool) -> bool { - state.actor_states.iter().any(|actor_state: &Arc| { - matches! ( - actor_state.next_step, - NextStep::Open {timeout} if timeout == expected_to_timeout - ) - }) -} - -fn unanimous_votes(model: &ActorModel, state: &ActorModelState) -> bool { - let peers: HashableHashSet = (0..model.cfg.n_nodes) - .map(|i| Id::from(i as usize)) - .collect(); - state.actor_states.iter().all(|actor_state: &Arc| { - actor_state.submitted_vote.is_some() - && peers.iter().all(|peer| { - actor_state - .submitted_vote - .clone() - .unwrap() - .1 - .recv - .iter() - .any(|g| g.src == *peer) - }) - }) -} - -fn majority_have_same_maximum(state: &ActorModelState) -> bool { - // get the chosen replica of each replica into a vector and sort that vector - // that there is only one value up to the n/2th index - let mut chosen_replicas: Vec = state - .actor_states - .iter() - .filter(|actor_state: &&Arc| actor_state.submitted_vote.is_some()) - .map(|actor_state| { - actor_state - .submitted_vote - .clone() - .unwrap() - .1 - .recv - .iter() - .max_by_key(|g| (g.txid, g.src)) - .unwrap() - .src - }) - .collect(); - chosen_replicas.sort(); - let majority_idx = state.actor_states.len() / 2; - let majority_chosen_replica = chosen_replicas.get(majority_idx); - majority_chosen_replica.is_some() - && chosen_replicas[0..majority_idx] - .iter() - .all(|&r| r == *majority_chosen_replica.unwrap()) -} - -fn liveness_properties(model: ActorModel) -> ActorModel { - let model = model - .property( - stateright::Expectation::Eventually, - "Unanimous votes => no chance of a fork", - |model: &ActorModel, state: &ActorModelState| { - // Define deadlock as a path which does not reach open without - // Hence unanimous votes => reach open - // Hence on every path unanimous votes => <> reached open - // Since votes are not forgotten on a node, we check for a state where unanimous votes => reached open - return implies( - unanimous_votes(model, state), - reached_open_timeout(state, false), - ); - }, - ) - .property( - stateright::Expectation::Eventually, - "Open", - |_, state: &ActorModelState| { - // all runs should eventually open, either via the reliable method, or via the failover timeout - reached_open(state) - }, - ) - .property( - stateright::Expectation::Eventually, - "Majority votes => no fork", - |_, state: &ActorModelState| { - return implies( - majority_have_same_maximum(state), - reached_open_timeout(state, false), - ); - }, - ); - return model; -} - -fn invariant_properties(model: ActorModel) -> ActorModel { - let model = model - .property( - stateright::Expectation::Always, - "No open with timeout, no fork", - |_model: &ActorModel, state: &ActorModelState| { - // Check if there is no fork in the state - let open_node_count = state - .actor_states - .iter() - .filter(|actor_state: &&Arc| { - matches!(actor_state.next_step, NextStep::Open { .. }) - }) - .count(); - implies(!reached_open_timeout(state, true), open_node_count <= 1) - }, - ) - .property( - stateright::Expectation::Always, - "Deadlock", - |_model, state| { - let all_open_join = state - .actor_states - .iter() - .all(|actor_state: &Arc| actor_state.next_step == NextStep::OpenJoin); - let all_votes_delivered = state - .network - .iter_all() - .filter(|msg| matches!(msg.msg, Msg::Vote(_))) - .count() - == 0; - !(all_open_join && all_votes_delivered) - }, - ) - .property( - stateright::Expectation::Always, - "Persist committed txs", - |_model: &ActorModel, state: &ActorModelState| { - let majority_idx = state.actor_states.len() / 2; - let commit_txid = state - .actor_states - .iter() - .map(|actor_state| actor_state.txid) - .collect::>()[majority_idx]; - let cond = state - .actor_states - .iter() - .filter(|actor_state: &&Arc| { - matches!(actor_state.next_step, NextStep::Open { .. }) - }) - .all(|actor_state: &Arc| actor_state.txid >= commit_txid); - implies(!reached_open_timeout(state, true), cond) - }, - ); - return model; -} - -fn reachable_properties(model: ActorModel) -> ActorModel { - let model = model - .property( - stateright::Expectation::Sometimes, - "Open is possible", - |_, state| implies(state.actor_states.len() > 1, reached_open(state)), - ) - .property( - stateright::Expectation::Sometimes, - "Unsafe open with timeout", - |_, state| reached_open_timeout(state, true), - ) - .property( - stateright::Expectation::Sometimes, - "Majority vote still opens without timeout", - |_model, state| majority_have_same_maximum(state) && reached_open_timeout(state, false), - ); - return model; -} - -fn properties(model: ActorModel) -> ActorModel { - let model = liveness_properties(model); - let model = invariant_properties(model); - let model = reachable_properties(model); - return model; -} - -#[derive(Parser, Debug)] -#[command(version, about = "Model for CCF's self-healing-open", long_about = None)] -struct CliArgs { - /// `global = true` lets this be given either before or after the - /// subcommand (e.g. `--n-nodes 3 check` or `export --nodes 3`); the - /// `nodes` alias matches the shared exporter invocation - /// `export --nodes N`. - #[clap(short, long, alias = "nodes", default_value = "3", global = true)] - n_nodes: usize, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Parser, Debug)] -enum Commands { - /// Check the model - Check, - /// Serve the model on localhost:8080 - Serve, - /// Export the exhaustive reachable state graph in a stable, canonical, - /// line-oriented text format (see Readme.md), suitable for byte-for-byte - /// comparison against an independent re-implementation of the model. - Export { - /// Output file path; defaults to stdout - #[clap(short, long)] - out: Option, - }, -} - -fn check(model: ActorModel) { - let checker = model - .checker() - .spawn_bfs() - .join_and_report(&mut WriteReporter::new(&mut std::io::stderr())); - checker.assert_properties(); -} - -fn serve(model: ActorModel) { - let checker = model.checker(); - println!("Serving model on http://localhost:8080"); - checker.serve("localhost:8080"); -} - -fn export(model: ActorModel, out: Option) { - match out { - Some(path) => { - let mut file = std::fs::File::create(&path) - .unwrap_or_else(|e| panic!("failed to create '{}': {}", path, e)); - export_graph(&model, &mut file).expect("failed to write model export"); - } - None => { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - export_graph(&model, &mut handle).expect("failed to write model export"); - } - } -} - -fn main() { - let args = CliArgs::parse(); - - let model = ModelCfg { - n_nodes: args.n_nodes, - } - .into_model(); - - let model = properties(model); - - match args.command { - Commands::Check => check(model), - Commands::Serve => serve(model), - Commands::Export { out } => export(model, out), - } -} diff --git a/tla/disaster-recovery/src/model.rs b/tla/disaster-recovery/src/model.rs deleted file mode 100644 index 735db3193186..000000000000 --- a/tla/disaster-recovery/src/model.rs +++ /dev/null @@ -1,193 +0,0 @@ -extern crate stateright; -use stateright::{actor::*, util::HashableHashSet}; -use std::borrow::Cow; - -type Txid = u64; - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct GossipStruct { - pub src: Id, - pub txid: Txid, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct VoteStruct { - pub src: Id, - pub recv: HashableHashSet, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub enum Msg { - Gossip(GossipStruct), - Vote(VoteStruct), - IAmOpen(Id), -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub enum Timer { - ElectionTimeout, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub enum NextStep { - Vote, - OpenJoin, - Open { timeout: bool }, - Join, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct State { - pub next_step: NextStep, - pub gossips: HashableHashSet, - pub votes: HashableHashSet, - pub submitted_vote: Option<(Id, VoteStruct)>, - pub txid: Txid, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct Node { - pub peers: HashableHashSet, -} - -impl Node { - fn vote_for_max<'a>(gossips: &HashableHashSet, id: Id) -> (Id, VoteStruct) { - let dst = gossips - .iter() - .max_by_key(|g| (g.txid, g.src)) - .unwrap() - .src; - let vote = VoteStruct { - src: id, - recv: gossips.clone(), - }; - return (dst, vote); - } - - fn other_peers(&self, id: Id) -> Vec { - self.peers.iter().filter(|&&p| p != id).cloned().collect() - } - - fn advance_step(&self, state: &mut State, o: &mut Out, id: Id, timeout: bool) -> bool { - match state.next_step { - NextStep::Vote if state.gossips.len() == self.peers.len() || timeout => { - let (dst, vote) = Node::vote_for_max(&state.gossips, id); - state.submitted_vote = Some((dst, vote.clone())); - if dst == id { - state.votes.insert(vote); - } else { - o.send(dst, Msg::Vote(vote)); - } - state.next_step = NextStep::OpenJoin; - return true; - } - NextStep::OpenJoin if state.votes.len() >= (self.peers.len() + 1) / 2 || timeout => { - state.next_step = NextStep::Open { timeout }; - o.broadcast(&self.other_peers(id), &Msg::IAmOpen(id)); - return true; - } - _ => false, - } - } - - fn advance_several(&self, state: &mut State, o: &mut Out, id: Id, timeout: bool) { - while self.advance_step(state, o, id, timeout) {} - } -} - -impl Actor for Node { - type Msg = Msg; - type State = State; - type Timer = Timer; - type Storage = (); - type Random = (); - - fn on_start(&self, id: Id, _storage: &Option, o: &mut Out) -> Self::State { - let txid = usize::from(id) as Txid; // Use id as txid for simplicity - let gossip = GossipStruct { src: id, txid }; - let mut gossips = HashableHashSet::new(); - gossips.insert(gossip.clone()); - let mut state = State { - next_step: NextStep::Vote, - gossips, - votes: HashableHashSet::new(), - submitted_vote: None, - txid: usize::from(id) as Txid, - }; - o.broadcast(&self.other_peers(id), &Msg::Gossip(gossip)); - o.set_timer(Timer::ElectionTimeout, model_timeout()); - self.advance_several(&mut state, o, id, false); - return state; - } - - fn on_timeout(&self, id: Id, state: &mut Cow, timer: &Timer, o: &mut Out) { - match timer { - Timer::ElectionTimeout => match state.next_step { - NextStep::Vote if !state.gossips.is_empty() => { - let state = state.to_mut(); - self.advance_several(state, o, id, true); - o.set_timer(Timer::ElectionTimeout, model_timeout()); - } - NextStep::OpenJoin if !state.votes.is_empty() => { - let state = state.to_mut(); - self.advance_several(state, o, id, true); - } - _ => { - o.set_timer(Timer::ElectionTimeout, model_timeout()); - } - }, - } - } - - fn on_msg( - &self, - id: Id, - state: &mut Cow, - _src: Id, - msg: Self::Msg, - o: &mut Out, - ) { - let state = state.to_mut(); - match msg { - Msg::Gossip(gossip) => { - // Freeze gossip collection after voting is submitted - if !state.gossips.contains(&gossip) && state.submitted_vote.is_none() { - state.gossips.insert(gossip.clone()); - } - } - Msg::Vote(vote) => { - if !state.votes.contains(&vote) { - state.votes.insert(vote); - } - } - Msg::IAmOpen(_) => { - if !matches!(state.next_step, NextStep::Open { .. }) { - state.next_step = NextStep::Join; - } - } - }; - self.advance_several(state, o, id, false); - } -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct ModelCfg { - pub n_nodes: usize, -} - -impl ModelCfg { - pub fn into_model(self) -> ActorModel { - let peers: HashableHashSet = (0..self.n_nodes).map(|i| Id::from(i as usize)).collect(); - ActorModel::new(self.clone(), ()) - .actors( - (0..self.n_nodes) - .map(|_| Node { - peers: peers.clone(), - }) - .collect::>(), - ) - //.init_network(Network::new_ordered([])) - .init_network(Network::new_unordered_nonduplicating([])) - .lossy_network(LossyNetwork::No) - } -} From 34c54c983373d5b885bc6065e8add771aac94766 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 11 Sep 2026 15:26:38 +0100 Subject: [PATCH 07/15] Fix HTTP/1.1 request-target size handling and add configuration (#8333) Co-authored-by: Amaury Chamayou Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + doc/host_config_schema/host_config.json | 5 + doc/schemas/node_openapi.json | 11 ++- include/ccf/http_configuration.h | 6 ++ include/ccf/odata_error.h | 1 + src/enclave/http_session.h | 16 ++++ src/enclave/rpc_sessions.h | 7 ++ src/http/error_reporter.h | 2 + src/http/http_exceptions.h | 8 ++ src/http/http_parser.h | 15 ++- src/http/test/http_test.cpp | 118 ++++++++++++++++++++++++ src/node/rpc/node_frontend.h | 2 +- src/node/rpc/test/frontend_test.cpp | 53 +++++++++++ src/node/session_metrics.h | 4 +- tests/e2e_common_endpoints.py | 45 ++++++--- tests/infra/e2e_args.py | 10 ++ tests/infra/interfaces.py | 6 ++ tests/infra/remote.py | 17 +++- 18 files changed, 305 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd07fc345f19..141b9f1d5e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed +- HTTP/1.x request targets, including query strings, are now bounded before accumulation by a new `max_request_target_size` setting (16 KB by default), independent of `max_header_size`. Oversized targets return HTTP 414 `RequestTargetTooLong`, increment the per-interface `request_target_too_long` error metric, and close the session. HTTP/2 limits are unchanged (#8333). - Updated QuickJS to `2026-06-04`, with isolated build-time patches for out-of-memory backtrace handling and enforcement of lowered heap limits (#8340). - CBOR parsing now rejects composite (array or map) and tagged values used as map keys anywhere in the decoded document, including nested maps in optional COSE headers (#8297). diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index 781cad582693..78a7b46f0be8 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -70,6 +70,11 @@ "default": "16KB", "description": "Maximum size (size string) of a single HTTP request header (key or value). Submitting a request with a header larger than this value will result in the client session being automatically closed" }, + "max_request_target_size": { + "type": "string", + "default": "16KB", + "description": "HTTP/1.x only. Maximum size (size string) of a single HTTP request target, including the query string. Submitting a request with a target larger than this value will result in the client session being automatically closed" + }, "max_headers_count": { "type": "integer", "default": 256, diff --git a/doc/schemas/node_openapi.json b/doc/schemas/node_openapi.json index c7e88b8ae774..edb604e822e9 100644 --- a/doc/schemas/node_openapi.json +++ b/doc/schemas/node_openapi.json @@ -666,6 +666,9 @@ }, "max_headers_count": { "$ref": "#/components/schemas/uint32" + }, + "max_request_target_size": { + "$ref": "#/components/schemas/SizeString" } }, "type": "object" @@ -828,12 +831,16 @@ }, "request_payload_too_large": { "$ref": "#/components/schemas/uint64" + }, + "request_target_too_long": { + "$ref": "#/components/schemas/uint64" } }, "required": [ "parsing", "request_payload_too_large", - "request_header_too_large" + "request_header_too_large", + "request_target_too_long" ], "type": "object" }, @@ -963,7 +970,7 @@ "info": { "description": "This API provides public, uncredentialed access to service and node state.", "title": "CCF Public Node API", - "version": "5.0.7" + "version": "5.0.8" }, "openapi": "3.0.0", "paths": { diff --git a/include/ccf/http_configuration.h b/include/ccf/http_configuration.h index ebe03a465100..5a50fd1d34f3 100644 --- a/include/ccf/http_configuration.h +++ b/include/ccf/http_configuration.h @@ -13,6 +13,7 @@ namespace ccf::http // requests that are too large. static const ccf::ds::SizeString default_max_body_size = {"1MB"}; static const ccf::ds::SizeString default_max_header_size = {"16KB"}; + static const ccf::ds::SizeString default_max_request_target_size = {"16KB"}; static const uint32_t default_max_headers_count = 256; // HTTP/2 only, as per nghttp2 defaults @@ -26,6 +27,9 @@ namespace ccf::http std::optional max_header_size = std::nullopt; std::optional max_headers_count = std::nullopt; + // HTTP/1.x only, including the query string. + std::optional max_request_target_size = std::nullopt; + // HTTP/2 only std::optional max_concurrent_streams_count = std::nullopt; std::optional initial_window_size = std::nullopt; @@ -42,6 +46,7 @@ namespace ccf::http max_body_size, max_header_size, max_headers_count, + max_request_target_size, max_concurrent_streams_count, initial_window_size, max_frame_size); @@ -53,6 +58,7 @@ namespace ccf::http ParserConfiguration config; config.max_body_size = "1GB"; config.max_header_size = "100MB"; + config.max_request_target_size = "100MB"; config.max_headers_count = 1024; config.max_concurrent_streams_count = 1; config.initial_window_size = "64KB"; diff --git a/include/ccf/odata_error.h b/include/ccf/odata_error.h index d3221d05993b..107160998fd2 100644 --- a/include/ccf/odata_error.h +++ b/include/ccf/odata_error.h @@ -83,6 +83,7 @@ namespace ccf ERROR(UnsupportedHttpVerb) ERROR(UnsupportedContentType) ERROR(RequestBodyTooLarge) + ERROR(RequestTargetTooLong) ERROR(RequestHeaderTooLarge) ERROR(PreconditionFailed) diff --git a/src/enclave/http_session.h b/src/enclave/http_session.h index 22db6151cc09..9947df55e1c8 100644 --- a/src/enclave/http_session.h +++ b/src/enclave/http_session.h @@ -71,6 +71,22 @@ namespace http close_session(); } + catch (RequestTargetTooLongException& e) + { + if (error_reporter) + { + error_reporter->report_request_target_too_long_error(interface_id); + } + + LOG_DEBUG_FMT("Request target is too long: {}", e.what()); + + send_odata_error_response(ccf::ErrorDetails{ + HTTP_STATUS_URI_TOO_LONG, + ccf::errors::RequestTargetTooLong, + e.what()}); + + close_session(); + } catch (RequestHeaderTooLargeException& e) { if (error_reporter) diff --git a/src/enclave/rpc_sessions.h b/src/enclave/rpc_sessions.h index 7f851a0c4e1e..8c016072f59d 100644 --- a/src/enclave/rpc_sessions.h +++ b/src/enclave/rpc_sessions.h @@ -187,6 +187,13 @@ namespace ccf get_interface_from_interface_id(id).errors.request_header_too_large++; } + void report_request_target_too_long_error( + const ccf::ListenInterfaceID& id) override + { + std::lock_guard guard(lock); + get_interface_from_interface_id(id).errors.request_target_too_long++; + } + void update_listening_interface_options( const ccf::NodeInfoNetwork& node_info) { diff --git a/src/http/error_reporter.h b/src/http/error_reporter.h index 33ed441394da..fad7663ab796 100644 --- a/src/http/error_reporter.h +++ b/src/http/error_reporter.h @@ -15,5 +15,7 @@ namespace http const ccf::ListenInterfaceID&) = 0; virtual void report_request_header_too_large_error( const ccf::ListenInterfaceID&) = 0; + virtual void report_request_target_too_long_error( + const ccf::ListenInterfaceID&) = 0; }; } \ No newline at end of file diff --git a/src/http/http_exceptions.h b/src/http/http_exceptions.h index a7e1d0dde1d7..170d1aee4a10 100644 --- a/src/http/http_exceptions.h +++ b/src/http/http_exceptions.h @@ -36,6 +36,14 @@ namespace http {} }; + class RequestTargetTooLongException : public RequestTooLargeException + { + public: + explicit RequestTargetTooLongException(const std::string& msg) : + RequestTooLargeException(msg) + {} + }; + class RequestHeaderTooLargeException : public RequestTooLargeException { public: diff --git a/src/http/http_parser.h b/src/http/http_parser.h index ae182397e6e7..93f46e8e12a7 100644 --- a/src/http/http_parser.h +++ b/src/http/http_parser.h @@ -406,6 +406,7 @@ namespace http RequestProcessor& proc; std::string url; + size_t max_request_target_size; public: ~RequestParser() override = default; @@ -415,13 +416,25 @@ namespace http const ccf::http::ParserConfiguration& config = ccf::http::ParserConfiguration{}) : Parser(HTTP_REQUEST, config), - proc(proc_) + proc(proc_), + max_request_target_size( + config.max_request_target_size + .value_or(ccf::http::default_max_request_target_size) + .count_bytes()) { settings.on_url = on_url; } void append_url(const char* at, size_t length) { + if ( + length > max_request_target_size || + url.size() > max_request_target_size - length) + { + throw RequestTargetTooLongException(fmt::format( + "HTTP request target is too long (max size allowed: {})", + max_request_target_size)); + } url.append(at, length); } diff --git a/src/http/test/http_test.cpp b/src/http/test/http_test.cpp index eb8feec6a3d7..edfa80cd3b78 100644 --- a/src/http/test/http_test.cpp +++ b/src/http/test/http_test.cpp @@ -363,6 +363,124 @@ DOCTEST_TEST_CASE("Body too large") } } +DOCTEST_TEST_CASE("Request target size limit") +{ + ccf::http::ParserConfiguration config; + DOCTEST_SUBCASE("Default limit") {} + DOCTEST_SUBCASE("Smaller configured limit") + { + config.max_request_target_size = "32B"; + } + DOCTEST_SUBCASE("Larger configured limit") + { + config.max_request_target_size = "32KB"; + } + DOCTEST_SUBCASE("Smaller header limit does not lower target default") + { + config.max_header_size = "32B"; + } + DOCTEST_SUBCASE("Larger header limit does not raise target default") + { + config.max_header_size = "32KB"; + } + + const auto limit = config.max_request_target_size + .value_or(ccf::http::default_max_request_target_size) + .count_bytes(); + + for (const auto size : {limit - 1, limit, limit + 1}) + { + for (const size_t chunk_size : {size_t{1}, size_t{7}, size + 4}) + { + DOCTEST_CAPTURE(size); + DOCTEST_CAPTURE(chunk_size); + http::SimpleRequestProcessor sp; + http::RequestParser p(sp, config); + const auto target = "/?q=" + std::string(size - 4, 'a'); + const auto prefix = s_to_v(("GET " + target).c_str()); + size_t offset = 0; + while (offset < prefix.size()) + { + const auto length = std::min(chunk_size, prefix.size() - offset); + if (size > limit && offset + length > limit + 4) + { + DOCTEST_CHECK_THROWS_AS( + p.execute(prefix.data() + offset, length), + http::RequestTargetTooLongException); + break; + } + DOCTEST_CHECK_NOTHROW(p.execute(prefix.data() + offset, length)); + offset += length; + } + DOCTEST_CHECK(sp.received.empty()); + if (size <= limit) + { + const auto suffix = s_to_v(" HTTP/1.1\r\n\r\n"); + p.execute(suffix.data(), suffix.size()); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == target); + sp.received.pop(); + + // The limit is per request, not per connection. + const auto next = http::Request(target, HTTP_GET).build_request(); + p.execute(next.data(), next.size()); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == target); + } + } + } +} + +DOCTEST_TEST_CASE("Request target rejected before append") +{ + ccf::http::ParserConfiguration config; + config.max_request_target_size = "4B"; + http::SimpleRequestProcessor sp; + http::RequestParser p(sp, config); + p.new_message(); + p.append_url("/abc", 4); + DOCTEST_CHECK_THROWS_AS( + p.append_url("d", 1), http::RequestTargetTooLongException); + p.end_message(); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == "/abc"); + + config.max_request_target_size = "0B"; + http::RequestParser zero_limit(sp, config); + DOCTEST_CHECK_THROWS_AS( + zero_limit.append_url("/", 1), http::RequestTargetTooLongException); +} + +DOCTEST_TEST_CASE("Request target configuration") +{ + const auto omitted = + nlohmann::json::object().get(); + DOCTEST_CHECK_FALSE(omitted.max_request_target_size.has_value()); + + const auto config = nlohmann::json{ + {"max_request_target_size", + "32KB"}}.get(); + DOCTEST_REQUIRE(config.max_request_target_size.has_value()); + DOCTEST_CHECK(config.max_request_target_size->count_bytes() == 32 * 1024); + DOCTEST_CHECK_FALSE(config.max_header_size.has_value()); + const nlohmann::json encoded = config; + DOCTEST_CHECK(encoded["max_request_target_size"] == "32KB"); + DOCTEST_CHECK(encoded.get() == config); + + const auto permissive_config = ccf::http::permissive_configuration(); + DOCTEST_REQUIRE(permissive_config.max_request_target_size.has_value()); + DOCTEST_CHECK( + permissive_config.max_request_target_size->count_bytes() == + 100 * 1024 * 1024); + http::SimpleRequestProcessor sp; + http::RequestParser p(sp, permissive_config); + const auto target = "/" + std::string(32 * 1024, 'a'); + const auto request = http::Request(target, HTTP_GET).build_request(); + p.execute(request.data(), request.size()); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == target); +} + DOCTEST_TEST_CASE("Multiple requests") { http::SimpleRequestProcessor sp; diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index ea1c6e5d3089..ae596eb13906 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -449,7 +449,7 @@ namespace ccf openapi_info.description = "This API provides public, uncredentialed access to service and node " "state."; - openapi_info.document_version = "5.0.7"; + openapi_info.document_version = "5.0.8"; } // NOLINTNEXTLINE(readability-function-cognitive-complexity) diff --git a/src/node/rpc/test/frontend_test.cpp b/src/node/rpc/test/frontend_test.cpp index cc8f78ed9205..5e2c79b18df1 100644 --- a/src/node/rpc/test/frontend_test.cpp +++ b/src/node/rpc/test/frontend_test.cpp @@ -1245,6 +1245,48 @@ TEST_CASE("Decoded Templated paths") } } +TEST_CASE("Forwarded request target limit" * doctest::test_suite("forwarding")) +{ + constexpr size_t forwarding_limit = 100 * 1024 * 1024; + auto target_size = forwarding_limit; + SUBCASE("At the forwarding limit") {} + SUBCASE("Above the forwarding limit") + { + target_size += 1; + } + const std::string prefix = "/app/empty_function?padding="; + const auto target = prefix + std::string(target_size - prefix.size(), 'a'); + const auto packed = ::http::Request(target, HTTP_POST).build_request(); + + ccf::http::ParserConfiguration config; + config.max_request_target_size = "101MB"; + { + ::http::SimpleRequestProcessor processor; + ::http::RequestParser ingress(processor, config); + ingress.execute(packed.data(), packed.size()); + REQUIRE(processor.received.size() == 1); + CHECK(processor.received.front().url == target); + } + + if (target_size > forwarding_limit) + { + CHECK_THROWS_AS( + ccf::make_fwd_rpc_context(user_session, packed, ccf::FrameFormat::http), + ::http::RequestTargetTooLongException); + } + else + { + auto forwarded = + ccf::make_fwd_rpc_context(user_session, packed, ccf::FrameFormat::http); + REQUIRE(forwarded != nullptr); + CHECK(forwarded->get_request_path() == "/app/empty_function"); + CHECK( + forwarded->get_request_query() == + std::string_view(target).substr(target.find('?') + 1)); + CHECK(forwarded->get_serialised_request() == packed); + } +} + TEST_CASE("Forwarding" * doctest::test_suite("forwarding")) { NetworkState network_primary; @@ -1477,8 +1519,18 @@ TEST_CASE("Userfrontend forwarding" * doctest::test_suite("forwarding")) publish_frontend_state(user_frontend_backup, network_backup); auto write_req = create_simple_request(); + write_req.set_query_param( + "padding", + std::string(ccf::http::default_max_request_target_size.count_bytes(), 'a')); auto serialized_call = write_req.build_request(); + ccf::http::ParserConfiguration ingress_config; + ingress_config.max_request_target_size = "32KB"; + ::http::SimpleRequestProcessor ingress_processor; + ::http::RequestParser ingress_parser(ingress_processor, ingress_config); + ingress_parser.execute(serialized_call.data(), serialized_call.size()); + REQUIRE(ingress_processor.received.size() == 1); + auto ctx = ccf::make_rpc_context(user_session, serialized_call); user_frontend_backup.process(ctx); REQUIRE(ctx->response_is_pending); @@ -1490,6 +1542,7 @@ TEST_CASE("Userfrontend forwarding" * doctest::test_suite("forwarding")) ccf::kv::test::FirstBackupNodeId, forwarded_msg.data(), forwarded_msg.size()); + REQUIRE(fwd_ctx != nullptr); user_frontend_primary.process_forwarded(fwd_ctx); auto response = parse_response(fwd_ctx->serialise_response()); diff --git a/src/node/session_metrics.h b/src/node/session_metrics.h index 632a37a06f3d..b70a0fc4c637 100644 --- a/src/node/session_metrics.h +++ b/src/node/session_metrics.h @@ -16,6 +16,7 @@ namespace ccf size_t parsing; size_t request_payload_too_large; size_t request_header_too_large; + size_t request_target_too_long; }; struct PerInterface @@ -37,7 +38,8 @@ namespace ccf SessionMetrics::Errors, parsing, request_payload_too_large, - request_header_too_large); + request_header_too_large, + request_target_too_long); DECLARE_JSON_TYPE(SessionMetrics::PerInterface); DECLARE_JSON_REQUIRED_FIELDS( diff --git a/tests/e2e_common_endpoints.py b/tests/e2e_common_endpoints.py index f95a5add1039..a7454a98a5d4 100644 --- a/tests/e2e_common_endpoints.py +++ b/tests/e2e_common_endpoints.py @@ -220,14 +220,15 @@ def run_large_message_test( metrics_name, length, *args, + path="/node/commit", **kwargs, ): with primary.client("user0") as client: - before_errors_count = get_main_interface_errors()[metrics_name] + before_errors = get_main_interface_errors() # Note: endpoint does not matter as request parsing is done before dispatch try: r = client.get( - "/node/commit", + path, *args, **kwargs, ) @@ -235,22 +236,17 @@ def run_large_message_test( # In some cases, the client ends up writing to the now-closed socket first # before reading the server error, resulting in a connection error assert length > threshold - assert ( - get_main_interface_errors()[metrics_name] == before_errors_count + 1 - ) else: if length > threshold: assert r.status_code == expected_status.value assert r.body.json()["error"]["code"] == expected_code - assert ( - get_main_interface_errors()[metrics_name] - == before_errors_count + 1 - ) else: assert r.status_code == http.HTTPStatus.OK.value - assert ( - get_main_interface_errors()[metrics_name] == before_errors_count - ) + + expected_errors = before_errors.copy() + if length > threshold: + expected_errors[metrics_name] += 1 + assert get_main_interface_errors() == expected_errors def get_sizes(n, http2): ns = [n // 2, n - 10, n - 1, n, n + 1, n + 10, n * 2] @@ -296,6 +292,31 @@ def get_sizes(n, http2): headers={long_header: "some header value"}, ) + if not args.http2: + for size in ( + args.max_http_request_target_size - 1, + args.max_http_request_target_size, + args.max_http_request_target_size + 1, + ): + prefix = "/node/commit?padding=" + if size < len(prefix): + LOG.warning( + f"Skipping {size} byte request target: the test endpoint " + f"requires at least {len(prefix)} bytes" + ) + continue + target = prefix + "a" * (size - len(prefix)) + assert len(target) == size + LOG.info(f"Verifying cap on request target, sending a {size} byte target") + run_large_message_test( + args.max_http_request_target_size, + http.HTTPStatus.REQUEST_URI_TOO_LONG, + "RequestTargetTooLong", + "request_target_too_long", + len(target), + path=target, + ) + # Note: infra generally inserts extra headers (eg, content type and length, user-agent, accept) extra_headers_count = infra.clients.CCFClient.default_impl_type.extra_headers_count( args.http2 diff --git a/tests/infra/e2e_args.py b/tests/infra/e2e_args.py index 7c0b2d4e8a84..bfc2b297dea1 100644 --- a/tests/infra/e2e_args.py +++ b/tests/infra/e2e_args.py @@ -77,6 +77,9 @@ "max_http_header_size": ( "network.rpc_interfaces.*.http_configuration.max_header_size" ), + "max_http_request_target_size": ( + "network.rpc_interfaces.*.http_configuration.max_request_target_size" + ), "max_http_headers_count": ( "network.rpc_interfaces.*.http_configuration.max_headers_count" ), @@ -143,6 +146,7 @@ def _convert_curve_id(value): "curve_id": _convert_curve_id, "max_http_body_size": _convert_size_string_to_bytes, "max_http_header_size": _convert_size_string_to_bytes, + "max_http_request_target_size": _convert_size_string_to_bytes, "http2": lambda value: value == "HTTP2", "tick_ms": lambda value: _convert_time_string(value, "ms"), } @@ -642,6 +646,12 @@ def cli_args( help="Maximum allowed size of single header in single HTTP request", default=1024 * 16, # 16KB ) + parser.add_argument( + "--max-http-request-target-size", + help="Maximum allowed size of an HTTP/1.x request target, including the query", + default=infra.interfaces.DEFAULT_MAX_HTTP_REQUEST_TARGET_SIZE, + type=int, + ) parser.add_argument( "--max-http-headers-count", help="Maximum number of headers in single HTTP request", diff --git a/tests/infra/interfaces.py b/tests/infra/interfaces.py index 0c284a5d2f98..aa0477b1193c 100644 --- a/tests/infra/interfaces.py +++ b/tests/infra/interfaces.py @@ -27,6 +27,7 @@ def make_address(host, port=0): DEFAULT_MAX_HTTP_BODY_SIZE = 1024 * 1024 DEFAULT_MAX_HTTP_HEADER_SIZE = 16 * 1024 +DEFAULT_MAX_HTTP_REQUEST_TARGET_SIZE = 16 * 1024 DEFAULT_MAX_HTTP_HEADERS_COUNT = 256 DEFAULT_MAX_CONCURRENT_STREAMS_COUNT = 100 @@ -180,6 +181,9 @@ class RPCInterface(Interface): max_http_header_size: int | None = field( default_factory=lambda: DEFAULT_MAX_HTTP_HEADER_SIZE ) + max_http_request_target_size: int | None = field( + default_factory=lambda: DEFAULT_MAX_HTTP_REQUEST_TARGET_SIZE + ) max_http_headers_count: int | None = field( default_factory=lambda: DEFAULT_MAX_HTTP_HEADERS_COUNT ) @@ -204,6 +208,7 @@ def apply_args(self, args): self.max_open_sessions_hard = args.max_open_sessions_hard self.max_http_body_size = args.max_http_body_size self.max_http_header_size = args.max_http_header_size + self.max_http_request_target_size = args.max_http_request_target_size self.max_http_headers_count = args.max_http_headers_count self.forwarding_timeout_ms = args.forwarding_timeout_ms self.app_protocol = "HTTP2" if args.http2 else "HTTP1" @@ -227,6 +232,7 @@ def to_json(interface): http_config = { "max_body_size": str(interface.max_http_body_size), "max_header_size": str(interface.max_http_header_size), + "max_request_target_size": str(interface.max_http_request_target_size), "max_headers_count": interface.max_http_headers_count, } if interface.app_protocol == "HTTP2": diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 31522f11a2c3..e55018104a68 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -580,6 +580,11 @@ def __init__( ) # Configuration file + v = ( + ccf._versionifier.to_python_version(version) + if version is not None + else None + ) if config_file: LOG.info( f"Node {self.local_node_id}: Using configuration file {config_file}" @@ -657,6 +662,13 @@ def __init__( # This will also ensure the render produced valid JSON j = json.loads(output) + # Releases before 7.0.16 reject this unknown HTTP configuration field. + if v is not None and v < Version("7.0.16"): + for interface in j["network"]["rpc_interfaces"].values(): + interface["http_configuration"].pop( + "max_request_target_size", None + ) + # Enclave config removed from 7.x onwards. if major_version is not None and major_version < 7: enclave_platform = infra.platform_detection.get_platform() @@ -695,11 +707,6 @@ def __init__( os.path.basename(config_file), ] - v = ( - ccf._versionifier.to_python_version(version) - if version is not None - else None - ) if v is None or v >= Version("7.0.0.dev0"): cmd += [ "--log-level", From 1d4aedff17784734ba352bb45937624e809ccead Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 11 Sep 2026 16:04:56 +0100 Subject: [PATCH 08/15] Apply batched suggestions from code review Co-authored-by: Amaury Chamayou --- src/node/historical_queries.h | 2 +- src/node/historical_queries_adapter.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/historical_queries.h b/src/node/historical_queries.h index 4db67a90525d..982041465a93 100644 --- a/src/node/historical_queries.h +++ b/src/node/historical_queries.h @@ -89,7 +89,7 @@ namespace ccf::historical } // This historical API exposes a single COSE signature, so receipts are - // described by the ECDSA one. + // described by the CLASSICAL one. static std::optional select_described_cose_signature( const ccf::CoseSignatureMap& cose_signatures) { diff --git a/src/node/historical_queries_adapter.cpp b/src/node/historical_queries_adapter.cpp index 666b424c7cc6..6962808ebe9f 100644 --- a/src/node/historical_queries_adapter.cpp +++ b/src/node/historical_queries_adapter.cpp @@ -269,7 +269,7 @@ namespace ccf std::optional describe_cose_signature_v1( const TxReceiptImpl& receipt) { - // This API exposes a single signature, so it returns the ECDSA one. + // This API exposes a single signature, so it returns the CLASSICAL one. const auto signature = receipt.cose_signatures.find(IdentityType::CLASSICAL); if (signature == receipt.cose_signatures.end()) From 5bcca65ea856fe02f2b59f8c148c7cf9d5b7df3f Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 11 Sep 2026 16:21:42 +0100 Subject: [PATCH 09/15] Fix delayed task clock ordering (#8337) Co-authored-by: achamayou Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/tasks/job_board.cpp | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/tasks/job_board.cpp b/src/tasks/job_board.cpp index daefcc2f2d9c..d25c39155549 100644 --- a/src/tasks/job_board.cpp +++ b/src/tasks/job_board.cpp @@ -47,10 +47,9 @@ namespace ccf::tasks using DelayedTasksByTime = std::map; - std::atomic total_elapsed = - std::chrono::milliseconds(0); - ccf::ds::Mutex tasks_mutex; + std::chrono::milliseconds total_elapsed CCF_GUARDED_BY(tasks_mutex) = + std::chrono::milliseconds(0); DelayedTasksByTime tasks CCF_GUARDED_BY(tasks_mutex); }; @@ -162,16 +161,17 @@ namespace ccf::tasks { ccf::ds::MutexGuard lock(delayed.tasks_mutex); - const auto trigger_time = delayed.total_elapsed.load() + initial_delay; + const auto trigger_time = delayed.total_elapsed + initial_delay; delayed.tasks[trigger_time].emplace_back(task, periodic_delay); } void tick(std::chrono::milliseconds elapsed) { - elapsed += delayed.total_elapsed.load(); - { ccf::ds::MutexGuard lock(delayed.tasks_mutex); + elapsed += delayed.total_elapsed; + delayed.total_elapsed = elapsed; + auto end_it = delayed.tasks.upper_bound(elapsed); Delayed::DelayedTasksByTime repeats; @@ -210,8 +210,6 @@ namespace ccf::tasks repeated_tasks.end()); } } - - delayed.total_elapsed.store(elapsed); } }; From c0b8a3b79083b5c9fa028b67677b0b833b4953f0 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 11 Sep 2026 16:59:48 +0100 Subject: [PATCH 10/15] Fix race condition in HistoricalExtension with request-scoped instances (#8355) --- CHANGELOG.md | 8 + python/pyproject.toml | 2 +- src/js/interpreter_cache.h | 1 + src/js/registry.cpp | 21 +- src/js/test/js.cpp | 224 +++++++++++++++++- .../custom_authorization.py | 41 ++++ tests/js-interpreter-reuse/app.json | 28 +++ .../js-interpreter-reuse/src/global_handle.ts | 50 +++- 8 files changed, 361 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 141b9f1d5e9c..8c5809a2f157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [7.0.16] + +[7.0.16]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.16 + +### Fixed + +- Historical states retrieved by JavaScript endpoints, through `ccf.historicalState` or `ccf.historical.getStateRange`, remain available through response conversion and are released when the request completes, rather than being retained for the lifetime of the node (#8355). + ## [7.0.15] [7.0.15]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.15 diff --git a/python/pyproject.toml b/python/pyproject.toml index 50017d2f73df..900ffe42262c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "7.0.15" +version = "7.0.16" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] diff --git a/src/js/interpreter_cache.h b/src/js/interpreter_cache.h index b7a5907c02cf..be8de0f0d4f9 100644 --- a/src/js/interpreter_cache.h +++ b/src/js/interpreter_cache.h @@ -4,6 +4,7 @@ #include "ccf/ds/locking.h" #include "ccf/js/interpreter_cache_interface.h" +#include "ds/internal_logger.h" #include "ds/lru.h" namespace ccf::js diff --git a/src/js/registry.cpp b/src/js/registry.cpp index b12dcd66d5b1..2c6a3a3d575e 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -232,6 +232,9 @@ namespace ccf::js } }; + // Historical reads remain valid in response getters and toJSON, but must + // still be released before the interpreter can serve another request. + ExtensionScope historical_extension_scope(ctx); ccf::js::core::JSWrappedValue val; { ExtensionScope extension_scope(ctx); @@ -242,6 +245,20 @@ namespace ccf::js extension_scope.add(extension); } + if (namespace_restriction) + { + // The live KvExtension takes precedence until handler teardown. + // After that, retain only its namespace policy for historical reads, + // including while extracting exceptions and converting the response. + historical_extension_scope.add( + std::make_shared( + nullptr, namespace_restriction)); + } + + historical_extension_scope.add( + std::make_shared( + &context.get_historical_state())); + if (pre_exec_hook.has_value()) { pre_exec_hook.value()(ctx); @@ -686,10 +703,6 @@ namespace ccf::js // add ccf.consensus.* extensions.emplace_back( std::make_shared(this)); - // add ccf.historical.* - extensions.emplace_back( - std::make_shared( - &context.get_historical_state())); interpreter_cache->set_interpreter_factory( [extensions](ccf::js::TxAccess access) { diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index 696307a320a2..922d5d230ae2 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -2,16 +2,22 @@ // Licensed under the Apache 2.0 License. #include "ccf/js/common_context.h" #include "ccf/js/core/wrapped_value.h" +#include "ccf/js/extensions/ccf/consensus.h" #include "ccf/js/extensions/ccf/crypto.h" #include "ccf/js/extensions/ccf/gov.h" #include "ccf/js/extensions/ccf/historical.h" #include "ccf/js/extensions/ccf/kv.h" #include "ccf/js/extensions/snp_attestation.h" +#include "ccf/js/registry.h" +#include "ccf/service/tables/modules.h" +#include "enclave/http_rpc_context.h" #include "js/global_class_ids.h" +#include "js/interpreter_cache.h" #include "js/permissions_checks.h" #include "kv/store.h" #include "kv/test/null_encryptor.h" #include "kv/untyped_map.h" +#include "node/rpc/test/node_stub.h" #include "node/tx_receipt_impl.h" #define DOCTEST_CONFIG_IMPLEMENT @@ -1766,7 +1772,7 @@ export function run() { } } -TEST_CASE("Historical state") +namespace { class CountingStore : public ccf::kv::Store { @@ -1780,14 +1786,22 @@ TEST_CASE("Historical state") } }; + ccf::TxReceiptImplPtr make_test_receipt() + { + return std::make_shared( + std::vector{1, 2, 3}, + std::nullopt, + ccf::HistoryTree::Hash{}, + nullptr, + ccf::NodeId("test-node"), + std::nullopt); + } +} + +TEST_CASE("Historical state") +{ auto store = std::make_shared(); - auto receipt = std::make_shared( - std::vector{1, 2, 3}, - std::nullopt, - ccf::HistoryTree::Hash{}, - nullptr, - ccf::NodeId("test-node"), - std::nullopt); + auto receipt = make_test_receipt(); auto state = std::make_shared(store, receipt, ccf::TxID{1, 1}); std::weak_ptr original_state = state; @@ -1837,6 +1851,200 @@ TEST_CASE("Historical state") REQUIRE(original_state.expired()); } +TEST_CASE("Historical handles are scoped to their extension") +{ + ccf::js::core::Context ctx(TxAccess::APP_RO); + auto receipt = make_test_receipt(); + + auto run_request = [&](ccf::SeqNo seqno, const auto& during_request) { + auto store = std::make_shared(); + std::weak_ptr weak_store = store; + auto state = std::make_shared( + store, receipt, ccf::TxID{1, seqno}); + std::weak_ptr weak_state = state; + + auto extension = + std::make_shared(nullptr); + ctx.add_extension(extension); + + auto js_state = extension->create_historical_state_object(ctx, state); + REQUIRE_FALSE(js_state.is_exception()); + auto map = js_state["kv"]["public:records"]; + REQUIRE_FALSE(map.is_exception()); + REQUIRE(ctx.to_str(map["size"]) == "0"); + REQUIRE(store->tx_creations == 1); + + during_request(); + + // End of request: the extension goes away, the interpreter and JS values + // remain + REQUIRE(ctx.remove_extension(extension)); + extension.reset(); + state.reset(); + store.reset(); + REQUIRE(weak_state.expired()); + REQUIRE(weak_store.expired()); + + return map; + }; + + auto expect_unavailable = [&](const ccf::js::core::JSWrappedValue& map) { + auto size = map["size"]; + REQUIRE(size.is_exception()); + auto [reason, trace] = ctx.error_message(); + REQUIRE(reason.find("Unable to access MapHandle") != std::string::npos); + }; + + auto stale_map = run_request(1, [] {}); + + { + INFO("A handle retained after its request completed fails gracefully"); + expect_unavailable(stale_map); + } + + run_request(2, [&] { + INFO("Handles from earlier requests are not visible to later ones"); + expect_unavailable(stale_map); + }); +} + +TEST_CASE("JS registry does not share historical state between interpreters") +{ + ccf::AbstractNodeContext context; + context.install_subsystem( + std::make_shared()); + auto interpreter_cache = std::make_shared(1); + context.install_subsystem( + interpreter_cache); + + [[maybe_unused]] ccf::js::BaseDynamicJSEndpointRegistry registry(context); + + for (const auto access : {TxAccess::APP_RO, TxAccess::APP_RW}) + { + auto interpreter = + interpreter_cache->get_interpreter(access, std::nullopt, 0); + REQUIRE(interpreter != nullptr); + REQUIRE( + interpreter->get_extension() != + nullptr); + REQUIRE( + interpreter->get_extension() == + nullptr); + } +} + +TEST_CASE("Historical response conversion preserves request isolation") +{ + class StateCache : public ccf::StubNodeStateCache + { + public: + std::weak_ptr latest_state; + + std::vector get_state_range( + ccf::historical::RequestHandle, + ccf::SeqNo, + ccf::SeqNo, + ccf::historical::ExpiryDuration) override + { + auto state = std::make_shared( + std::make_shared(), + make_test_receipt(), + ccf::TxID{1, 1}); + latest_state = state; + return {state}; + } + }; + + ccf::AbstractNodeContext context; + auto state_cache = std::make_shared(); + context.install_subsystem(state_cache); + auto interpreter_cache = std::make_shared(1); + context.install_subsystem( + interpreter_cache); + ccf::js::BaseDynamicJSEndpointRegistry registry(context, "public:test"); + registry.set_js_kv_namespace_restriction( + [](const std::string& map_name, std::string& explanation) { + explanation = "Restricted test table"; + return map_name == "public:restricted" ? KVAccessPermissions::ILLEGAL : + KVAccessPermissions::READ_WRITE; + }); + + ccf::kv::Store store; + store.set_encryptor(std::make_shared()); + { + auto tx = store.create_tx(); + tx.rw("public:test.modules")->put("/response.js", R"( +export function run(request) { + const live = ccf.kv["public:records"]; + if (live.size !== 0) { + throw new Error("Unexpected live state"); + } + const state = ccf.historical.getStateRange(1, 1, 1, 1)[0]; + if (request.query === "handler_throw") { + throw new Error("Handler failure"); + } + return { + body: { + toJSON() { + if (request.query === "restricted") { + return state.kv["public:restricted"].size; + } + if (request.query === "live") { + return live.size; + } + if (request.query === "throw") { + throw new Error("Response failure"); + } + return state.kv["public:records"].size; + } + } + }; +} +)"); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + auto endpoint = std::make_shared(); + endpoint->properties.js_module = "/response.js"; + endpoint->properties.js_function = "run"; + endpoint->properties.mode = ccf::endpoints::Mode::ReadWrite; + endpoint->properties.interpreter_reuse = + ccf::endpoints::InterpreterReusePolicy{.key = "historical"}; + + for (const std::string query : + {"ok", "restricted", "live", "throw", "handler_throw", "ok"}) + { + INFO(query); + auto rpc_ctx = std::make_shared( + std::make_shared( + ccf::InvalidSessionId, std::vector{}), + ccf::HttpVersion::HTTP1, + HTTP_GET, + "/response?" + query, + ccf::http::HeaderMap{}, + std::vector{}); + auto tx = store.create_tx(); + ccf::endpoints::EndpointContext endpoint_ctx(rpc_ctx, tx); + registry.execute_endpoint(endpoint, endpoint_ctx); + REQUIRE( + rpc_ctx->get_response_status() == + (query == "ok" ? HTTP_STATUS_OK : HTTP_STATUS_INTERNAL_SERVER_ERROR)); + if (query == "ok") + { + REQUIRE(nlohmann::json::parse(rpc_ctx->get_response_body()) == 0); + } + REQUIRE(state_cache->latest_state.expired()); + auto interpreter = interpreter_cache->get_interpreter( + TxAccess::APP_RW, endpoint->properties.interpreter_reuse, 0); + REQUIRE( + interpreter->get_extension() == + nullptr); + REQUIRE( + interpreter->get_extension() == + nullptr); + } +} + int main(int argc, char** argv) { ccf::js::register_class_ids(); diff --git a/tests/js-custom-authorization/custom_authorization.py b/tests/js-custom-authorization/custom_authorization.py index 1a528de70629..e82684059722 100644 --- a/tests/js-custom-authorization/custom_authorization.py +++ b/tests/js-custom-authorization/custom_authorization.py @@ -14,6 +14,7 @@ from functools import partial from http import HTTPStatus +import infra.clients import infra.e2e_args import infra.net import infra.network @@ -1428,6 +1429,45 @@ def make_body(): return network +@reqs.description("Historical state remains available through response conversion") +def test_historical_response_conversion(network, args): + primary, _ = network.find_nodes() + with primary.client() as c: + writes = [] + for _ in range(2): + r = c.post("/app/increment") + assert r.status_code == http.HTTPStatus.OK, r + c.wait_for_commit(r) + writes.append(r) + + for write in writes: + expected_value = write.body.json()["value"] + for path in ("/app/historical", f"/app/historical/range/{write.seqno}"): + headers = { + infra.clients.CCF_TX_ID_HEADER: f"{write.view}.{write.seqno}" + } + timeout = time.time() + 10 + while True: + r = c.get(path, headers=headers) + if r.status_code != http.HTTPStatus.ACCEPTED: + break + assert time.time() < timeout, r + time.sleep(0.1) + + assert r.status_code == http.HTTPStatus.OK, r + assert r.body.json() == {"value": expected_value}, r + assert r.headers["x-historical-value"] == str(expected_value), r + + for fail in ("body", "json"): + r = c.get(path, headers={**headers, "x-throw": fail}) + assert r.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR, r + r = c.get(path, headers=headers) + assert r.status_code == http.HTTPStatus.OK, r + assert r.body.json() == {"value": expected_value}, r + + return network + + def test_caching_of_app_code(network, args): primary, backups = network.find_nodes() LOG.info( @@ -1471,6 +1511,7 @@ def run_interpreter_reuse(args): network = test_reused_interpreter_behaviour(network, args) network = test_caching_of_kv_handles(network, args) + network = test_historical_response_conversion(network, args) network = test_caching_of_app_code(network, args) diff --git a/tests/js-interpreter-reuse/app.json b/tests/js-interpreter-reuse/app.json index 77cacc0f3402..4cd9e1526c72 100644 --- a/tests/js-interpreter-reuse/app.json +++ b/tests/js-interpreter-reuse/app.json @@ -95,6 +95,34 @@ } } }, + "/historical": { + "get": { + "js_module": "global_handle.js", + "js_function": "historical", + "forwarding_required": "never", + "redirection_strategy": "none", + "authn_policies": ["no_auth"], + "mode": "historical", + "openapi": {}, + "interpreter_reuse": { + "key": "historical_response" + } + } + }, + "/historical/range/{seqno}": { + "get": { + "js_module": "global_handle.js", + "js_function": "historical", + "forwarding_required": "never", + "redirection_strategy": "none", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { + "key": "historical_response" + } + } + }, "/func_caching": { "get": { "js_module": "func_caching.js", diff --git a/tests/js-interpreter-reuse/src/global_handle.ts b/tests/js-interpreter-reuse/src/global_handle.ts index e709e6699b6d..23b04aa71af2 100644 --- a/tests/js-interpreter-reuse/src/global_handle.ts +++ b/tests/js-interpreter-reuse/src/global_handle.ts @@ -80,4 +80,52 @@ function increment() { return { body: { value: v } }; } -export { globals, increment }; +function historical(request) { + const seqno = Number(request.params.seqno); + const getState = request.params.seqno + ? () => ccf.historical.getStateRange(seqno, seqno, seqno, 180)?.[0] + : () => ccf.historicalState; + if (!getState()) { + return { statusCode: 202 }; + } + + const read = () => + ccfapp.uint32.decode( + getState().kv["public:cached_handle_table"].get( + ccfapp.string.encode("single_key"), + ), + ); + const fail = request.headers["x-throw"]; + + // These reads happen after the endpoint handler has returned. + return { + get body() { + read(); + if (fail === "body") { + throw new Error("Historical response body failure"); + } + return { + toJSON() { + const value = read(); + if (fail === "json") { + throw new Error("Historical response JSON failure"); + } + return { value }; + }, + }; + }, + get headers() { + return { + get "x-historical-value"() { + return `${read()}`; + }, + }; + }, + get statusCode() { + read(); + return 200; + }, + }; +} + +export { globals, increment, historical }; From 47213ecf1fac4d35097f58299b9425c9fdb31bba Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 11 Sep 2026 17:41:36 +0100 Subject: [PATCH 11/15] Always release the value passed to JSWrappedValue::set on failure (#8356) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eddyashton <6000239+eddyashton@users.noreply.github.com> --- CHANGELOG.md | 1 + src/js/core/wrapped_value.cpp | 26 ++++----- src/js/test/js.cpp | 104 ++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5809a2f157..9a3d239c8420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- Fixed a double free when setting a property on a JavaScript object fails, which application script could trigger while the request object was being built. Such failures are now reported as a failed request (#8356). - Historical states retrieved by JavaScript endpoints, through `ccf.historicalState` or `ccf.historical.getStateRange`, remain available through response conversion and are released when the request completes, rather than being retained for the lifetime of the node (#8355). ## [7.0.15] diff --git a/src/js/core/wrapped_value.cpp b/src/js/core/wrapped_value.cpp index 5fddcf3a652f..45b9f240835a 100644 --- a/src/js/core/wrapped_value.cpp +++ b/src/js/core/wrapped_value.cpp @@ -73,12 +73,10 @@ namespace ccf::js::core int JSWrappedValue::set(const char* prop, JSWrappedValue&& value) const { - int rc = JS_SetPropertyStr(ctx, val, prop, value.val); - if (rc == 1) - { - value.val = ccf::js::core::constants::Null; - } - return rc; + // JS_SetPropertyStr takes ownership of the value on every return path, + // including failure, so we call .take() to always drop our local owning + // reference + return JS_SetPropertyStr(ctx, val, prop, value.take()); } int JSWrappedValue::set_getter( @@ -91,9 +89,8 @@ namespace ccf::js::core return -1; } - // NB: Where other calls check the return code to determine whether they - // are responsible for freeing, this call unconditionally frees the getter - // arg, so we call .take() to always drop our local owning reference + // NB: This call unconditionally frees the getter arg, so we call .take() to + // always drop our local owning reference int rc = JS_DefinePropertyGetSet( ctx, val, @@ -141,13 +138,10 @@ namespace ccf::js::core int JSWrappedValue::set_at_index(uint32_t index, JSWrappedValue&& value) const { - int rc = - JS_DefinePropertyValueUint32(ctx, val, index, value.val, JS_PROP_C_W_E); - if (rc == 1) - { - value.val = ccf::js::core::constants::Null; - } - return rc; + // As with set(), JS_DefinePropertyValueUint32 takes ownership of the value + // on every return path, including failure + return JS_DefinePropertyValueUint32( + ctx, val, index, value.take(), JS_PROP_C_W_E); } bool JSWrappedValue::is_exception() const diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index 922d5d230ae2..33e73cbb6bab 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -1335,6 +1335,110 @@ TEST_CASE("JSWrappedValue copy assignment frees old value") JS_FreeRuntime(rt); } +// QuickJS frees the value passed to JS_SetProperty* and JS_DefinePropertyValue* +// on every return path, so the wrapper must relinquish its reference even when +// the set fails. Script can make these fail by placing a setter or read-only +// property on the target's prototype chain, or by making the target +// non-extensible, before the C++ side populates an object. +TEST_CASE("JSWrappedValue setters release the value when the set fails") +{ + ccf::js::core::Context ctx(TxAccess::APP_RW); + JS_UpdateStackTop(ctx.runtime()); + + // Drain any pending exception and return its message + auto pending_exception = [&]() { + REQUIRE(JS_HasException(ctx) != 0); + return ctx.error_message().first; + }; + + SUBCASE("Non-extensible target") + { + auto target = ctx.new_obj(); + REQUIRE(JS_PreventExtensions(ctx, target.val) == 1); + + auto value = ctx.new_obj(); + JSValue raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + REQUIRE(target.set("x", std::move(value)) == -1); + REQUIRE(get_ref_count(raw) == 1); + CHECK(pending_exception() == "TypeError: object is not extensible"); + + JS_FreeValue(ctx, raw); + } + + SUBCASE("Inherited throwing setter which retains the value") + { + auto handler = ctx.get_exported_function( + "Object.defineProperty(Object.prototype, 'headers', {" + " set(v) { globalThis.leaked = v; throw new Error('boom'); }," + " configurable: true });" + "export function handler() { globalThis.leaked.tag = 'ok'; " + "return JSON.stringify(globalThis.leaked); }", + "handler", + "/test/poisoned_setter"); + + auto target = ctx.new_obj(); + JSValue raw = ctx.undefined().val; + { + auto value = ctx.new_obj(); + raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + REQUIRE(target.set("headers", std::move(value)) == -1); + CHECK(pending_exception() == "Error: boom"); + } + // Held by raw and by globalThis.leaked + REQUIRE(get_ref_count(raw) == 2); + + // The stashed value must still be a valid object + auto result = ctx.inner_call(handler, {}); + REQUIRE_FALSE(result.is_exception()); + CHECK(ctx.to_str(result).value() == "{\"tag\":\"ok\"}"); + + JS_FreeValue(ctx, raw); + } + + SUBCASE("Inherited read-only data property") + { + ctx.get_exported_function( + "Object.defineProperty(Object.prototype, 'headers', {" + " value: 1, writable: false, configurable: true });" + "export function handler() {}", + "handler", + "/test/poisoned_readonly"); + + auto target = ctx.new_obj(); + auto value = ctx.new_obj(); + JSValue raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + REQUIRE(target.set("headers", std::move(value)) == -1); + REQUIRE(get_ref_count(raw) == 1); + CHECK(pending_exception() == "TypeError: 'headers' is read-only"); + + JS_FreeValue(ctx, raw); + } + + SUBCASE("set_at_index on a non-extensible array") + { + auto target = ctx.new_array(); + REQUIRE(JS_PreventExtensions(ctx, target.val) == 1); + + auto value = ctx.new_obj(); + JSValue raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + // Define does not request JS_PROP_THROW, so this is a rejection (0) + // rather than an exception (-1), but the value is consumed either way + REQUIRE(target.set_at_index(0, std::move(value)) == 0); + REQUIRE(get_ref_count(raw) == 1); + CHECK(JS_HasException(ctx) == 0); + + JS_FreeValue(ctx, raw); + } +} + TEST_CASE("QuickJS rejects arena allocations above a lowered heap limit") { ccf::js::core::Context ctx(TxAccess::APP_RW); From b8f3cf92d26ababd06c798a459e1fc21ade9bcd4 Mon Sep 17 00:00:00 2001 From: cjen1-msft Date: Fri, 11 Sep 2026 18:20:02 +0100 Subject: [PATCH 12/15] Use TAV for SNP attestation verification (#8083) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 + CMakeLists.txt | 10 + cmake/ccf_rs.cmake | 19 + cmake/crypto.cmake | 8 +- include/ccf/node/quote.h | 7 + include/ccf/pal/attestation.h | 7 + include/ccf/pal/attestation_sev_snp.h | 123 ++++- include/ccf/pal/snp_ioctl.h | 2 + include/ccf/pal/snp_ioctl6.h | 110 +++- src/js/extensions/snp_attestation.cpp | 474 +++++++++++------- src/node/node_state.h | 33 +- src/node/quote.cpp | 71 ++- src/node/rpc/node_frontend.h | 3 +- .../rpc/test/internal_tables_access_test.cpp | 11 + src/pal/attestation.cpp | 235 ++++----- src/pal/quote_generation.h | 14 +- src/pal/test/snp_attestation_validation.cpp | 383 +++++++++++++- src/pal/test/snp_ioctl_test.cpp | 13 +- src/pal/test/verify_attestation.cpp | 19 +- ...erify_uvm_attestation_and_endorsements.cpp | 11 +- src/service/internal_tables_access.h | 34 +- tests/npm_tests.py | 6 + 22 files changed, 1149 insertions(+), 450 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3d239c8420..72b28f9aca9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixed a double free when setting a property on a JavaScript object fails, which application script could trigger while the request object was being built. Such failures are now reported as a failed request (#8356). - Historical states retrieved by JavaScript endpoints, through `ccf.historicalState` or `ccf.historical.getStateRange`, remain available through response conversion and are released when the request completes, rather than being retained for the lifetime of the node (#8355). +- JavaScript `verifySnpAttestation()` and the deprecated C++ `ccf::pal::snp::Attestation` returned swapped `current_minor` and `current_build` values. Both now match the AMD SEV-SNP report layout, with `current_build` at offset `0x1E8` and `current_minor` at `0x1E9` (#8083). + +### Changed + +- SNP attestation reports are now parsed and verified through TAV. Decode a report with `ccf::pal::snp::parse_attestation_report_unverified()`, which returns `ccf::pal::snp::AttestationReport`, an owning smart pointer, and verify it against TAV and CCF's policy with `ccf::pal::verify_snp_attestation_report_and_get()`. Field accessors borrow the report's storage, so destroying or replacing the owner invalidates them. The packed `ccf::pal::snp::Attestation` wire-layout type and its accessors still work, but are deprecated (#8083). +- `ccf::pal::snp::get_attestation()` in `ccf/pal/snp_ioctl.h` is unchanged, but its `get()` accessor is deprecated. Call `get_raw()` instead for the unverified report bytes, then decode them with `parse_attestation_report_unverified()` (#8083). ## [7.0.15] diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c542bc5dc75..9ada2f7d7a07 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -263,6 +263,10 @@ add_ccf_static_library( SRCS ${CCF_DIR}/src/pal/attestation.cpp LINK_LIBS ccfcrypto ) +target_include_directories( + ccf_pal + PRIVATE ${CCF_DIR}/3rdparty/internal/tee-attestation-verification/ffi/include +) # CCF js lib add_ccf_static_library( @@ -510,6 +514,11 @@ install( PATTERN "*.inc" ) +install( + FILES ${TAV_INCLUDE_DIR}/tav/snp.h ${TAV_INCLUDE_DIR}/tav/utils.h + DESTINATION include/3rdparty/tav +) + # Install all private CCF headers, which may still be needed install( DIRECTORY src/ @@ -598,6 +607,7 @@ if(BUILD_TESTS) snp_ioctl_test ${CMAKE_CURRENT_SOURCE_DIR}/src/pal/test/snp_ioctl_test.cpp ) + target_link_libraries(snp_ioctl_test PRIVATE ccf_pal) set_property(TEST snp_ioctl_test APPEND PROPERTY LABELS snp) set_property(TEST snp_ioctl_test APPEND PROPERTY CONFIGURATIONS snp) diff --git a/cmake/ccf_rs.cmake b/cmake/ccf_rs.cmake index d24e557d8c0e..eecf172092c8 100644 --- a/cmake/ccf_rs.cmake +++ b/cmake/ccf_rs.cmake @@ -75,10 +75,29 @@ add_custom_target( "${CCF_RS_DIR}/rust-toolchain.toml" "${CCF_DIR}/src/cose/cose_rs/Cargo.toml" "${CCF_DIR}/3rdparty/internal/cose-openssl/Cargo.toml" + "${CCF_DIR}/3rdparty/internal/tee-attestation-verification/ffi/Cargo.toml" COMMENT "Building ${CCF_RS_PACKAGE} Rust static library (Cargo profile: ${CCF_RS_CARGO_PROFILE_NAME})" USES_TERMINAL VERBATIM ) +add_library(ccf_rs INTERFACE) +target_link_libraries( + ccf_rs + INTERFACE + $ + $ + ssl + crypto +) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_libraries( + ccf_rs + INTERFACE ${CMAKE_THREAD_LIBS_INIT} ${CMAKE_DL_LIBS} m + ) +endif() +add_dependencies(ccf_rs cargo-build_ccf_rs) + install(FILES "${CCF_RS_LIB_BUILD_PATH}" DESTINATION lib) +install(TARGETS ccf_rs EXPORT ccf) diff --git a/cmake/crypto.cmake b/cmake/crypto.cmake index da483a15ee08..b18a33f19205 100644 --- a/cmake/crypto.cmake +++ b/cmake/crypto.cmake @@ -43,13 +43,7 @@ add_hardening(ccfcrypto) add_tidy(ccfcrypto) target_link_libraries(ccfcrypto PUBLIC crypto ssl ccf_threading) -target_link_libraries( - ccfcrypto - PUBLIC - $ - $ -) -add_dependencies(ccfcrypto cargo-build_ccf_rs) +target_link_libraries(ccfcrypto PUBLIC ccf_rs) set_property(TARGET ccfcrypto PROPERTY POSITION_INDEPENDENT_CODE ON) install(TARGETS ccfcrypto EXPORT ccf DESTINATION lib) diff --git a/include/ccf/node/quote.h b/include/ccf/node/quote.h index ac9e983ab0ce..b28442e045ec 100644 --- a/include/ccf/node/quote.h +++ b/include/ccf/node/quote.h @@ -39,8 +39,15 @@ namespace ccf static std::optional get_host_data(const QuoteInfo& quote_info); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + [[deprecated("Use get_snp_attestation_report")]] static std::optional get_snp_attestation( const QuoteInfo& quote_info); +#pragma GCC diagnostic pop + + static std::optional + get_snp_attestation_report(const QuoteInfo& quote_info); static QuoteVerificationResult verify_quote_against_store( ccf::kv::ReadOnlyTx& tx, diff --git a/include/ccf/pal/attestation.h b/include/ccf/pal/attestation.h index 19fc6ed55ab1..d3f1fbb7e4ee 100644 --- a/include/ccf/pal/attestation.h +++ b/include/ccf/pal/attestation.h @@ -3,6 +3,7 @@ #pragma once #include "ccf/ds/quote_info.h" +#include "ccf/pal/attestation_sev_snp.h" #include "ccf/pal/attestation_sev_snp_endorsements.h" #include "ccf/pal/measurement.h" #include "ccf/pal/report_data.h" @@ -28,6 +29,12 @@ namespace ccf::pal PlatformAttestationMeasurement& measurement, PlatformAttestationReportData& report_data); + /// Verify with TAV, then enforce CCF's SNP attestation policy. + snp::AttestationReport verify_snp_attestation_report_and_get( + const QuoteInfo& quote_info, + PlatformAttestationMeasurement& measurement, + PlatformAttestationReportData& report_data); + void verify_quote( const QuoteInfo& quote_info, PlatformAttestationMeasurement& measurement, diff --git a/include/ccf/pal/attestation_sev_snp.h b/include/ccf/pal/attestation_sev_snp.h index 600ed237a403..fafae2660b17 100644 --- a/include/ccf/pal/attestation_sev_snp.h +++ b/include/ccf/pal/attestation_sev_snp.h @@ -15,9 +15,13 @@ #include #include #include +#include #include +#include #include #include +#include +#include #include namespace ccf::pal::snp @@ -28,6 +32,7 @@ namespace ccf::pal::snp static constexpr auto NO_SECURITY_POLICY = ""; // From https://developer.amd.com/sev/ + [[deprecated("TAV verifies AMD root signing keys internally")]] constexpr auto amd_milan_root_signing_public_key = R"(-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0Ld52RJOdeiJlqK2JdsV @@ -44,6 +49,7 @@ pCCoMNit2uLo9M18fHz10lOMT8nWAUvRZFzteXCm+7PHdYPlmQwUw3LvenJ/ILXo QPHfbkH0CyPfhl1jWhJFZasCAwEAAQ== -----END PUBLIC KEY----- )"; + [[deprecated("TAV verifies AMD root signing keys internally")]] constexpr auto amd_genoa_root_signing_public_key = R"(-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3Cd95S/uFOuRIskW9vz9 @@ -60,6 +66,7 @@ HP1qYrnvhzaG1S70vw6OkbaaC9EjiH/uHgAJQGxon7u0Q7xgoREWA/e7JcBQwLg8 0Hq/sbRuqesxz7wBWSY254cCAwEAAQ== -----END PUBLIC KEY----- )"; + [[deprecated("TAV verifies AMD root signing keys internally")]] constexpr auto amd_turin_root_signing_public_key = R"(-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwaAriB7EIuVc4ZB1wD3Y @@ -77,12 +84,14 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== -----END PUBLIC KEY----- )"; - struct AmdRootSigningKey + struct [[deprecated( + "TAV verifies AMD root signing keys internally")]] AmdRootSigningKey { const char* public_key; const char* issuer; }; + [[deprecated("TAV verifies AMD root signing keys internally")]] inline const std::map amd_root_signing_keys{ {ProductName::Milan, {amd_milan_root_signing_public_key, @@ -222,15 +231,14 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== TcbVersionRaw() = default; - TcbVersionRaw(const std::vector& data) + TcbVersionRaw(std::span data) { if (data.size() != snp_tcb_version_size) { throw std::logic_error( fmt::format("Invalid TCB version raw data size: {}", data.size())); } - std::memcpy( - static_cast(underlying_data), data.data(), snp_tcb_version_size); + std::memcpy(underlying_data, data.data(), snp_tcb_version_size); } [[nodiscard]] std::vector data() const @@ -392,6 +400,10 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== sizeof(PlatformInfo) == sizeof(uint64_t), "Cannot cast PlatformInfo to uint64_t"); + static constexpr size_t attestation_report_size = 1184; + + struct [[deprecated("Use ccf::pal::snp::AttestationReport")]] Attestation; + #pragma pack(push, 1) // Table 21 @@ -425,8 +437,8 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== uint8_t reserved1[21] = {0}; /* 0x18B */ uint8_t chip_id[64] = {0}; /* 0x1A0 */ TcbVersionRaw committed_tcb; /* 0x1E0 */ - uint8_t current_minor = 0; /* 0x1E8 */ - uint8_t current_build = 0; /* 0x1E9 */ + uint8_t current_build = 0; /* 0x1E8 */ + uint8_t current_minor = 0; /* 0x1E9 */ uint8_t current_major = 0; /* 0x1EA */ uint8_t reserved2 = 0; /* 0x1EB */ uint8_t committed_build = 0; /* 0x1EC */ @@ -456,6 +468,49 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== }; #pragma pack(pop) + // Reports are allocated in Rust and must be freed through TAV, not C++ + // delete. A stateless deleter keeps the smart pointer default-constructible + // without storing a cleanup function pointer. + struct AttestationReportDeleter + { + void operator()(TavSnpAttestationReport* report) const noexcept + { + tav_snp_attestation_report_free(report); + } + }; + + using AttestationReport = + std::unique_ptr; + + inline std::span get_chip_id_for_vcek( + const AttestationReport& report) + { + if (report == nullptr) + { + throw std::logic_error("Cannot access an empty SNP attestation report"); + } + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_chip_id(report.get(), &data, &size); + const auto chip_id = std::span{data, size}; + const auto product = get_sev_snp_product( + tav_snp_attestation_report_cpuid_fam_id(report.get()), + tav_snp_attestation_report_cpuid_mod_id(report.get())); + if (product == ProductName::Milan || product == ProductName::Genoa) + { + return chip_id; + } + if (product == ProductName::Turin) + { + return chip_id.first(8); + } + throw std::logic_error( + fmt::format("Unsupported SEV-SNP product: {}", product)); + } + + [[nodiscard]] AttestationReport parse_attestation_report_unverified( + std::span report); + static HostPort get_endpoint_loc( const EndorsementsServer& server, const HostPort& default_values) { @@ -475,24 +530,37 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== static EndorsementEndpointsConfiguration make_endorsement_endpoint_configuration( - const Attestation& quote, + const AttestationReport& quote, const snp::EndorsementsServers& endorsements_servers = {}) { - if (quote.version < minimum_attestation_version) + if (quote == nullptr) + { + throw std::logic_error("Cannot access an empty SNP attestation report"); + } + if ( + tav_snp_attestation_report_version(quote.get()) < + minimum_attestation_version) { throw std::logic_error(fmt::format( "SEV-SNP: attestation version {} is not supported. Minimum " "supported version is {}", - quote.version, + tav_snp_attestation_report_version(quote.get()), minimum_attestation_version)); } EndorsementEndpointsConfiguration config; auto chip_id_hex = - fmt::format("{:02x}", fmt::join(quote.get_chip_id_for_vcek(), "")); + fmt::format("{:02x}", fmt::join(get_chip_id_for_vcek(quote), "")); + const uint8_t* reported_tcb_data = nullptr; + size_t reported_tcb_size = 0; + tav_snp_attestation_report_reported_tcb( + quote.get(), &reported_tcb_data, &reported_tcb_size); + const auto reported_tcb_raw = + std::span{reported_tcb_data, reported_tcb_size}; auto reported_tcb = fmt::format( - "{:0x}", *reinterpret_cast("e.reported_tcb)); + "{:02x}", + fmt::join(reported_tcb_raw.rbegin(), reported_tcb_raw.rend(), "")); constexpr size_t default_max_retries_count = 10; static const ds::SizeString default_max_client_response_size = @@ -533,8 +601,9 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== } case EndorsementsEndpointType::AMD: { - auto product = - get_sev_snp_product(quote.cpuid_fam_id, quote.cpuid_mod_id); + auto product = get_sev_snp_product( + tav_snp_attestation_report_cpuid_fam_id(quote.get()), + tav_snp_attestation_report_cpuid_mod_id(quote.get())); std::string boot_loader; std::string tee; @@ -546,7 +615,9 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== case ProductName::Milan: case ProductName::Genoa: { - auto tcb = quote.reported_tcb.to_policy(product).to_milan_genoa(); + auto tcb = TcbVersionRaw(reported_tcb_raw) + .to_policy(product) + .to_milan_genoa(); boot_loader = fmt::format("{}", tcb.boot_loader); tee = fmt::format("{}", tcb.tee); snp = fmt::format("{}", tcb.snp); @@ -555,7 +626,8 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== } case ProductName::Turin: { - auto tcb = quote.reported_tcb.to_policy(product).to_turin(); + auto tcb = + TcbVersionRaw(reported_tcb_raw).to_policy(product).to_turin(); boot_loader = fmt::format("{}", tcb.boot_loader); tee = fmt::format("{}", tcb.tee); snp = fmt::format("{}", tcb.snp); @@ -608,14 +680,33 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== return config; } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + [[deprecated("Use the AttestationReport overload")]] + static EndorsementEndpointsConfiguration + make_endorsement_endpoint_configuration( + const Attestation& quote, + const snp::EndorsementsServers& endorsements_servers = {}) + { + const auto* report = reinterpret_cast("e); + return make_endorsement_endpoint_configuration( + parse_attestation_report_unverified({report, attestation_report_size}), + endorsements_servers); + } + class AttestationInterface { public: - [[nodiscard]] virtual const snp::Attestation& get() const = 0; + [[deprecated( + "Use get_raw() and explicitly decode with " + "parse_attestation_report_unverified")]] [[nodiscard]] virtual const snp:: + Attestation& + get() const = 0; virtual std::vector get_raw() = 0; virtual ~AttestationInterface() = default; }; +#pragma GCC diagnostic pop } diff --git a/include/ccf/pal/snp_ioctl.h b/include/ccf/pal/snp_ioctl.h index 445a9b9a210f..7a23eee47c46 100644 --- a/include/ccf/pal/snp_ioctl.h +++ b/include/ccf/pal/snp_ioctl.h @@ -11,6 +11,8 @@ namespace ccf::pal::snp return ioctl6::supports_sev_snp(); } + // Acquire an attestation object. get_raw() returns owned, unverified bytes; + // decode them explicitly with parse_attestation_report_unverified(). static std::unique_ptr get_attestation( const PlatformAttestationReportData& report_data) { diff --git a/include/ccf/pal/snp_ioctl6.h b/include/ccf/pal/snp_ioctl6.h index 344febe028d4..0b6ffdce2f66 100644 --- a/include/ccf/pal/snp_ioctl6.h +++ b/include/ccf/pal/snp_ioctl6.h @@ -24,6 +24,31 @@ namespace ccf::pal::snp::ioctl6 { constexpr auto DEVICE = "/dev/sev-guest"; + namespace detail + { + // Linux snp_guest_msg is 4096 bytes: a 96-byte outer message header and + // 4000-byte payload. The ioctl returns only the decrypted payload, with + // its own 32-byte report response header before the report. + // https://github.com/torvalds/linux/blob/v6.8/drivers/virt/coco/sev-guest/sev-guest.h + // https://github.com/torvalds/linux/blob/v6.8/include/uapi/linux/sev-guest.h + constexpr size_t ATTESTATION_RESPONSE_SIZE = 4000; + struct AttestationResponse + { + uint32_t status = 0; + uint32_t report_size = 0; + std::array reserved = {}; + std::array report_bytes = {}; + std::array< + uint8_t, + detail::ATTESTATION_RESPONSE_SIZE - 0x20 - attestation_report_size> + padding = {}; + }; + static_assert( + sizeof(AttestationResponse) == detail::ATTESTATION_RESPONSE_SIZE); + static_assert(offsetof(AttestationResponse, report_size) == 0x04); + static_assert(offsetof(AttestationResponse, report_bytes) == 0x20); + } + #pragma pack(push, 1) // Helper to add padding to a struct, so that the resulting struct has some // minimum size. As a minor detail, the padding will be initialised to 0. @@ -110,17 +135,24 @@ namespace ccf::pal::snp::ioctl6 #pragma pack(pop) // Table 25 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" #pragma pack(push, 1) struct AttestationResp { uint32_t status = 0; uint32_t report_size = 0; uint8_t reserved[0x20 - 0x8] = {0}; - Attestation report; + [[deprecated("Use get_raw() and parse_attestation_report_unverified")]] + snp::Attestation report = {}; uint8_t padding[64] = {0}; // padding to the size of SEV_SNP_REPORT_RSP_BUF_SZ (i.e., 1280 bytes) }; #pragma pack(pop) + static_assert(offsetof(AttestationResp, report_size) == 0x04); + static_assert(offsetof(AttestationResp, report) == 0x20); + static_assert(sizeof(AttestationResp) == 1280); +#pragma GCC diagnostic pop // Table 20 of the SEVSNP ABI constexpr uint8_t GUEST_FIELD_SELECT_GUEST_POLICY = 0b00000001; @@ -193,10 +225,25 @@ namespace ccf::pal::snp::ioctl6 using GuestRequestDerivedKey = GuestRequest; + namespace detail + { + using AttestationRequest = + GuestRequest; + static_assert( + sizeof(AttestationRequest) == sizeof(GuestRequestAttestation)); + static_assert(offsetof(AttestationRequest, req_data) == 8); + static_assert(offsetof(AttestationRequest, resp_wrapper) == 16); + static_assert(offsetof(AttestationRequest, exit_info) == 24); + static_assert(sizeof(AttestationRequest) == 32); + } + // From linux/include/uapi/linux/sev-guest.h constexpr char SEV_GUEST_IOC_TYPE = 'S'; constexpr int SEV_SNP_GUEST_MSG_REPORT = - _IOWR(SEV_GUEST_IOC_TYPE, 0x0, GuestRequestAttestation); + _IOWR(SEV_GUEST_IOC_TYPE, 0x0, detail::AttestationRequest); + static_assert( + _IOWR(SEV_GUEST_IOC_TYPE, 0x0, detail::AttestationRequest) == + _IOWR(SEV_GUEST_IOC_TYPE, 0x0, GuestRequestAttestation)); constexpr int SEV_SNP_GUEST_MSG_DERIVED_KEY = _IOWR(SEV_GUEST_IOC_TYPE, 0x1, GuestRequestDerivedKey); @@ -205,13 +252,11 @@ namespace ccf::pal::snp::ioctl6 return access(DEVICE, W_OK) == 0; } - class Attestation : public AttestationInterface + namespace detail { - IoctlSentinel resp_with_sentinel; - PaddedAttestationResp& padded_resp = resp_with_sentinel.data; - - public: - Attestation(const PlatformAttestationReportData& report_data) + inline void request_attestation( + const PlatformAttestationReportData& report_data, + IoctlSentinel& response) { AttestationReq req = {}; if (report_data.data.size() <= snp_attestation_report_data_size) @@ -235,8 +280,8 @@ namespace ccf::pal::snp::ioctl6 // Documented at // https://www.kernel.org/doc/html/latest/virt/coco/sev-guest.html - GuestRequestAttestation payload = { - .req_data = &req, .resp_wrapper = &padded_resp, .exit_info = {0}}; + AttestationRequest payload = { + .req_data = &req, .resp_wrapper = &response.data, .exit_info = {0}}; int rc = ioctl(fd, SEV_SNP_GUEST_MSG_REPORT, &payload); if (rc < 0) @@ -250,7 +295,7 @@ namespace ccf::pal::snp::ioctl6 throw std::logic_error(msg); } - if (!resp_with_sentinel.sentinels_intact()) + if (!response.sentinels_intact()) { // This occurs if a kernel/firmware upgrade causes the response to // overflow our struct. If that happens, it is better to fail early than @@ -259,17 +304,54 @@ namespace ccf::pal::snp::ioctl6 "SEV_SNP_GUEST_MSG_REPORT IOCTL overwrote safety sentinels."); } } + } + + class Attestation : public AttestationInterface + { + PaddedAttestationResp padded_resp; + + public: + Attestation(const PlatformAttestationReportData& report_data) + { + IoctlSentinel response; + detail::request_attestation(report_data, response); + // Retain legacy storage for the reference returned by get(). + static_assert(sizeof(padded_resp) == sizeof(response.data)); + std::memcpy(&padded_resp, &response.data, sizeof(padded_resp)); + } - [[nodiscard]] const snp::Attestation& get() const override +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + [[deprecated( + "Use get_raw() and explicitly decode with " + "parse_attestation_report_unverified")]] [[nodiscard]] const ccf::pal:: + snp::Attestation& + get() const override { + if (padded_resp.report_size != attestation_report_size) + { + throw std::logic_error(fmt::format( + "Unexpected SEV-SNP attestation report size: {} != {}", + padded_resp.report_size, + attestation_report_size)); + } return padded_resp.report; } std::vector get_raw() override { - auto* quote_bytes = reinterpret_cast(&padded_resp.report); - return {quote_bytes, quote_bytes + padded_resp.report_size}; + if (padded_resp.report_size != attestation_report_size) + { + throw std::logic_error(fmt::format( + "Unexpected SEV-SNP attestation report size: {} != {}", + padded_resp.report_size, + attestation_report_size)); + } + const auto* report = + reinterpret_cast(&padded_resp.report); + return {report, report + attestation_report_size}; } +#pragma GCC diagnostic pop }; class DerivedKey diff --git a/src/js/extensions/snp_attestation.cpp b/src/js/extensions/snp_attestation.cpp index 245c2268410f..aa24eb15a898 100644 --- a/src/js/extensions/snp_attestation.cpp +++ b/src/js/extensions/snp_attestation.cpp @@ -22,9 +22,9 @@ namespace ccf::js::extensions { JSValue make_js_tcb_version( - js::core::Context& jsctx, pal::snp::TcbVersionRaw tcb) + js::core::Context& jsctx, std::span tcb) { - auto data_hex = jsctx.new_string(tcb.to_hex()); + auto data_hex = jsctx.new_string(pal::snp::TcbVersionRaw(tcb).to_hex()); JS_CHECK_EXC(data_hex); return data_hex.take(); } @@ -94,10 +94,9 @@ namespace ccf::js::extensions pal::PlatformAttestationMeasurement measurement = {}; pal::PlatformAttestationReportData report_data = {}; std::optional parsed_uvm_endorsements; - try { - pal::verify_snp_attestation_report( + const auto attestation = pal::verify_snp_attestation_report_and_get( quote_info, measurement, report_data); if (uvm_endorsements.has_value()) { @@ -107,223 +106,316 @@ namespace ccf::js::extensions measurement, default_uvm_roots_of_trust); } - } - catch (const std::exception& e) - { - return JS_ThrowRangeError(ctx, "%s", e.what()); - } - - auto attestation = *reinterpret_cast( - quote_info.quote.data()); - - auto r = jsctx.new_obj(); - JS_CHECK_EXC(r); - - auto a = jsctx.new_obj(); - JS_CHECK_EXC(a); - - JS_CHECK_SET(a.set_uint32("version", attestation.version)); - JS_CHECK_SET(a.set_uint32("guest_svn", attestation.guest_svn)); - - auto policy = jsctx.new_obj(); - JS_CHECK_EXC(policy); - - JS_CHECK_SET( - policy.set_uint32("abi_minor", attestation.policy.abi_minor)); - JS_CHECK_SET( - policy.set_uint32("abi_major", attestation.policy.abi_major)); - JS_CHECK_SET(policy.set_uint32("smt", attestation.policy.smt)); - JS_CHECK_SET( - policy.set_uint32("migrate_ma", attestation.policy.migrate_ma)); - JS_CHECK_SET(policy.set_uint32("debug", attestation.policy.debug)); - JS_CHECK_SET( - policy.set_uint32("single_socket", attestation.policy.single_socket)); + auto r = jsctx.new_obj(); + JS_CHECK_EXC(r); + + auto a = jsctx.new_obj(); + JS_CHECK_EXC(a); + + JS_CHECK_SET(a.set_uint32( + "version", tav_snp_attestation_report_version(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "guest_svn", + tav_snp_attestation_report_guest_svn(attestation.get()))); + + auto policy = jsctx.new_obj(); + JS_CHECK_EXC(policy); + + JS_CHECK_SET(policy.set_uint32( + "abi_minor", + tav_snp_attestation_report_policy_abi_minor(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "abi_major", + tav_snp_attestation_report_policy_abi_major(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "smt", tav_snp_attestation_report_policy_smt(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "migrate_ma", + tav_snp_attestation_report_policy_migrate_ma(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "debug", tav_snp_attestation_report_policy_debug(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "single_socket", + tav_snp_attestation_report_policy_single_socket(attestation.get()))); + + JS_CHECK_SET(a.set("policy", std::move(policy))); - JS_CHECK_SET(a.set("policy", std::move(policy))); - - { - auto family_id = jsctx.new_array_buffer_copy(attestation.family_id); - JS_CHECK_EXC(family_id); - JS_CHECK_SET(a.set("family_id", std::move(family_id))); - } - - { - auto image_id = jsctx.new_array_buffer_copy(attestation.image_id); - JS_CHECK_EXC(image_id); - JS_CHECK_SET(a.set("image_id", std::move(image_id))); - } - - JS_CHECK_SET(a.set_uint32("vmpl", attestation.vmpl)); - JS_CHECK_SET(a.set_uint32( - "signature_algo", static_cast(attestation.signature_algo))); - - { - auto platform_version = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.platform_version)); - JS_CHECK_EXC(platform_version); - JS_CHECK_SET(a.set("platform_version", std::move(platform_version))); - } - - { - auto platform_info = jsctx.new_obj(); - JS_CHECK_EXC(platform_info); - JS_CHECK_SET( - platform_info.set_uint32("smt_en", attestation.platform_info.smt_en)); - JS_CHECK_SET(platform_info.set_uint32( - "tsme_en", attestation.platform_info.tsme_en)); - JS_CHECK_SET(a.set("plaform_info", std::move(platform_info))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_family_id(attestation.get(), &data, &size); + auto family_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(family_id); + JS_CHECK_SET(a.set("family_id", std::move(family_id))); + } - { - auto flags = jsctx.new_obj(); - JS_CHECK_EXC(flags); - JS_CHECK_SET( - flags.set_uint32("author_key_en", attestation.flags.author_key_en)); - JS_CHECK_SET( - flags.set_uint32("mask_chip_key", attestation.flags.mask_chip_key)); - JS_CHECK_SET( - flags.set_uint32("signing_key", attestation.flags.signing_key)); - JS_CHECK_SET(a.set("flags", std::move(flags))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_image_id(attestation.get(), &data, &size); + auto image_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(image_id); + JS_CHECK_SET(a.set("image_id", std::move(image_id))); + } - { - auto attestation_report_data = - jsctx.new_array_buffer_copy(attestation.report_data); - JS_CHECK_EXC(attestation_report_data); - JS_CHECK_SET(a.set("report_data", std::move(attestation_report_data))); - } + JS_CHECK_SET(a.set_uint32( + "vmpl", tav_snp_attestation_report_vmpl(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "signature_algo", + static_cast( + tav_snp_attestation_report_signature_algo(attestation.get())))); - { - auto attestation_measurement = - jsctx.new_array_buffer_copy(attestation.measurement); - JS_CHECK_EXC(attestation_measurement); - JS_CHECK_SET(a.set("measurement", std::move(attestation_measurement))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_platform_version( + attestation.get(), &data, &size); + auto platform_version = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(platform_version); + JS_CHECK_SET(a.set("platform_version", std::move(platform_version))); + } - { - auto attestation_host_data = - jsctx.new_array_buffer_copy(attestation.host_data); - JS_CHECK_EXC(attestation_host_data); - JS_CHECK_SET(a.set("host_data", std::move(attestation_host_data))); - } + { + auto platform_info = jsctx.new_obj(); + JS_CHECK_EXC(platform_info); + const auto raw_platform_info = + tav_snp_attestation_report_platform_info(attestation.get()); + JS_CHECK_SET( + platform_info.set_uint32("smt_en", raw_platform_info & 1)); + JS_CHECK_SET( + platform_info.set_uint32("tsme_en", (raw_platform_info >> 1) & 1)); + JS_CHECK_SET(a.set("plaform_info", std::move(platform_info))); + } - { - auto attestation_id_key_digest = - jsctx.new_array_buffer_copy(attestation.id_key_digest); - JS_CHECK_EXC(attestation_id_key_digest); - JS_CHECK_SET( - a.set("id_key_digest", std::move(attestation_id_key_digest))); - } + { + auto flags = jsctx.new_obj(); + JS_CHECK_EXC(flags); + JS_CHECK_SET(flags.set_uint32( + "author_key_en", + tav_snp_attestation_report_flags_author_key_en(attestation.get()))); + JS_CHECK_SET(flags.set_uint32( + "mask_chip_key", + tav_snp_attestation_report_flags_mask_chip_key(attestation.get()))); + JS_CHECK_SET(flags.set_uint32( + "signing_key", + tav_snp_attestation_report_flags_signing_key(attestation.get()))); + JS_CHECK_SET(a.set("flags", std::move(flags))); + } - { - auto attestation_author_key_digest = - jsctx.new_array_buffer_copy(attestation.author_key_digest); - JS_CHECK_EXC(attestation_author_key_digest); - JS_CHECK_SET( - a.set("author_key_digest", std::move(attestation_author_key_digest))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_data( + attestation.get(), &data, &size); + auto attestation_report_data = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_report_data); + JS_CHECK_SET( + a.set("report_data", std::move(attestation_report_data))); + } - { - auto attestation_report_id = - jsctx.new_array_buffer_copy(attestation.report_id); - JS_CHECK_EXC(attestation_report_id); - JS_CHECK_SET(a.set("report_id", std::move(attestation_report_id))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_measurement( + attestation.get(), &data, &size); + auto attestation_measurement = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_measurement); + JS_CHECK_SET( + a.set("measurement", std::move(attestation_measurement))); + } - { - auto attestation_report_id_ma = - jsctx.new_array_buffer_copy(attestation.report_id_ma); - JS_CHECK_EXC(attestation_report_id_ma); - JS_CHECK_SET( - a.set("report_id_ma", std::move(attestation_report_id_ma))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_host_data(attestation.get(), &data, &size); + auto attestation_host_data = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_host_data); + JS_CHECK_SET(a.set("host_data", std::move(attestation_host_data))); + } - { - auto reported_tcb = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.reported_tcb)); - JS_CHECK_EXC(reported_tcb); - JS_CHECK_SET(a.set("reported_tcb", std::move(reported_tcb))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_id_key_digest( + attestation.get(), &data, &size); + auto attestation_id_key_digest = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_id_key_digest); + JS_CHECK_SET( + a.set("id_key_digest", std::move(attestation_id_key_digest))); + } - JS_CHECK_SET(a.set_uint32("cpuid_fam_id", attestation.cpuid_fam_id)); - JS_CHECK_SET(a.set_uint32("cpuid_mod_id", attestation.cpuid_mod_id)); - JS_CHECK_SET(a.set_uint32("cpuid_step", attestation.cpuid_step)); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_author_key_digest( + attestation.get(), &data, &size); + auto attestation_author_key_digest = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_author_key_digest); + JS_CHECK_SET(a.set( + "author_key_digest", std::move(attestation_author_key_digest))); + } - { - auto attestation_chip_id = - jsctx.new_array_buffer_copy(attestation.chip_id); - JS_CHECK_EXC(attestation_chip_id); - JS_CHECK_SET(a.set("chip_id", std::move(attestation_chip_id))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_id(attestation.get(), &data, &size); + auto attestation_report_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_report_id); + JS_CHECK_SET(a.set("report_id", std::move(attestation_report_id))); + } - { - auto committed_tcb = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.committed_tcb)); - JS_CHECK_EXC(committed_tcb); - JS_CHECK_SET(a.set("committed_tcb", std::move(committed_tcb))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_id_ma( + attestation.get(), &data, &size); + auto attestation_report_id_ma = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_report_id_ma); + JS_CHECK_SET( + a.set("report_id_ma", std::move(attestation_report_id_ma))); + } - JS_CHECK_SET(a.set_uint32("current_minor", attestation.current_minor)); - JS_CHECK_SET(a.set_uint32("current_build", attestation.current_build)); - JS_CHECK_SET(a.set_uint32("current_major", attestation.current_major)); - JS_CHECK_SET( - a.set_uint32("committed_build", attestation.committed_build)); - JS_CHECK_SET( - a.set_uint32("committed_minor", attestation.committed_minor)); - JS_CHECK_SET( - a.set_uint32("committed_major", attestation.committed_major)); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + attestation.get(), &data, &size); + auto reported_tcb = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(reported_tcb); + JS_CHECK_SET(a.set("reported_tcb", std::move(reported_tcb))); + } - { - auto launch_tcb = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.launch_tcb)); - JS_CHECK_EXC(launch_tcb); - JS_CHECK_SET(a.set("launch_tcb", std::move(launch_tcb))); - } + JS_CHECK_SET(a.set_uint32( + "cpuid_fam_id", + tav_snp_attestation_report_cpuid_fam_id(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "cpuid_mod_id", + tav_snp_attestation_report_cpuid_mod_id(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "cpuid_step", + tav_snp_attestation_report_cpuid_step(attestation.get()))); - auto signature = jsctx.new_obj(); - JS_CHECK_EXC(signature); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_chip_id(attestation.get(), &data, &size); + auto attestation_chip_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_chip_id); + JS_CHECK_SET(a.set("chip_id", std::move(attestation_chip_id))); + } - { - auto signature_r = jsctx.new_array_buffer_copy(attestation.signature.r); - JS_CHECK_EXC(signature_r); - JS_CHECK_SET(signature.set("r", std::move(signature_r))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_committed_tcb( + attestation.get(), &data, &size); + auto committed_tcb = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(committed_tcb); + JS_CHECK_SET(a.set("committed_tcb", std::move(committed_tcb))); + } - { - auto signature_s = jsctx.new_array_buffer_copy(attestation.signature.s); - JS_CHECK_EXC(signature_s); - JS_CHECK_SET(signature.set("s", std::move(signature_s))); - } + JS_CHECK_SET(a.set_uint32( + "current_minor", + tav_snp_attestation_report_current_minor(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "current_build", + tav_snp_attestation_report_current_build(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "current_major", + tav_snp_attestation_report_current_major(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "committed_build", + tav_snp_attestation_report_committed_build(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "committed_minor", + tav_snp_attestation_report_committed_minor(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "committed_major", + tav_snp_attestation_report_committed_major(attestation.get()))); - JS_CHECK_SET(a.set("signature", std::move(signature))); - JS_CHECK_SET(r.set("attestation", std::move(a))); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_launch_tcb( + attestation.get(), &data, &size); + auto launch_tcb = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(launch_tcb); + JS_CHECK_SET(a.set("launch_tcb", std::move(launch_tcb))); + } - if (parsed_uvm_endorsements.has_value()) - { - auto u = jsctx.new_obj(); - JS_CHECK_EXC(u); + auto signature = jsctx.new_obj(); + JS_CHECK_EXC(signature); { - auto did = jsctx.new_string(parsed_uvm_endorsements.value().did); - JS_CHECK_EXC(did); - JS_CHECK_SET(u.set("did", std::move(did))); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_signature_r( + attestation.get(), &data, &size); + auto signature_r = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(signature_r); + JS_CHECK_SET(signature.set("r", std::move(signature_r))); } { - auto feed = jsctx.new_string(parsed_uvm_endorsements.value().feed); - JS_CHECK_EXC(feed); - JS_CHECK_SET(u.set("feed", std::move(feed))); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_signature_s( + attestation.get(), &data, &size); + auto signature_s = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(signature_s); + JS_CHECK_SET(signature.set("s", std::move(signature_s))); } + JS_CHECK_SET(a.set("signature", std::move(signature))); + JS_CHECK_SET(r.set("attestation", std::move(a))); + + if (parsed_uvm_endorsements.has_value()) { - auto svn = jsctx.new_string(parsed_uvm_endorsements.value().svn); - JS_CHECK_EXC(svn); - JS_CHECK_SET(u.set("svn", std::move(svn))); - JS_CHECK_SET(r.set("uvm_endorsements", std::move(u))); + auto u = jsctx.new_obj(); + JS_CHECK_EXC(u); + + { + auto did = jsctx.new_string(parsed_uvm_endorsements.value().did); + JS_CHECK_EXC(did); + JS_CHECK_SET(u.set("did", std::move(did))); + } + + { + auto feed = jsctx.new_string(parsed_uvm_endorsements.value().feed); + JS_CHECK_EXC(feed); + JS_CHECK_SET(u.set("feed", std::move(feed))); + } + + { + auto svn = jsctx.new_string(parsed_uvm_endorsements.value().svn); + JS_CHECK_EXC(svn); + JS_CHECK_SET(u.set("svn", std::move(svn))); + JS_CHECK_SET(r.set("uvm_endorsements", std::move(u))); + } } - } - return r.take(); + return r.take(); + } + catch (const std::exception& e) + { + return JS_ThrowRangeError(ctx, "%s", e.what()); + } } #pragma clang diagnostic pop diff --git a/src/node/node_state.h b/src/node/node_state.h index c60aa82d8a79..9c91c512e4c1 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -881,10 +881,14 @@ namespace ccf } auto snp_attestation = - AttestationProvider::get_snp_attestation(quote_info); + AttestationProvider::get_snp_attestation_report(quote_info); if (snp_attestation.has_value()) { - snp_tcb_version = snp_attestation.value().reported_tcb; + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + snp_attestation.value().get(), &data, &size); + snp_tcb_version = ccf::pal::snp::TcbVersionRaw({data, size}); } // Verify that the security policy matches the quoted digest of the policy @@ -1032,19 +1036,18 @@ namespace ccf // Check that tcbm in endorsement matches reported TCB in our // retrieved attestation - const auto* quote = - reinterpret_cast( - quote_info.quote.data()); - const auto reported_tcb = quote->reported_tcb; - - // tcbm is a single hex value, like DB18000000000004. To match - // that with a TcbVersion, reverse the bytes. - const auto* tcb_begin = - reinterpret_cast(&reported_tcb); - const std::span tcb_bytes{ - tcb_begin, tcb_begin + sizeof(reported_tcb)}; - auto tcb_as_hex = fmt::format( - "{:02x}", fmt::join(tcb_bytes.rbegin(), tcb_bytes.rend(), "")); + const auto report = + ccf::pal::snp::parse_attestation_report_unverified( + quote_info.quote); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + report.get(), &data, &size); + const auto reported_tcb = + ccf::pal::snp::TcbVersionRaw({data, size}); + + // tcbm is a single hex value, like DB18000000000004. + auto tcb_as_hex = reported_tcb.to_hex(); ccf::nonstd::to_upper(tcb_as_hex); if (tcb_as_hex == aci_endorsements.tcbm) diff --git a/src/node/quote.cpp b/src/node/quote.cpp index 03ef0db0b30e..9606d60571b9 100644 --- a/src/node/quote.cpp +++ b/src/node/quote.cpp @@ -22,6 +22,7 @@ #include "node/js_policy.h" #include "node/uvm_endorsements.h" +#include #include namespace ccf @@ -153,8 +154,8 @@ namespace ccf return measurement; } - std::optional AttestationProvider::get_snp_attestation( - const QuoteInfo& quote_info) + std::optional AttestationProvider:: + get_snp_attestation_report(const QuoteInfo& quote_info) { if (quote_info.format != QuoteFormat::amd_sev_snp_v1) { @@ -164,10 +165,7 @@ namespace ccf { pal::PlatformAttestationMeasurement d = {}; pal::PlatformAttestationReportData r = {}; - pal::verify_quote(quote_info, d, r); - auto attestation = *reinterpret_cast( - quote_info.quote.data()); - return attestation; + return pal::verify_snp_attestation_report_and_get(quote_info, d, r); } catch (const std::exception& e) { @@ -176,6 +174,33 @@ namespace ccf } } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + std::optional AttestationProvider::get_snp_attestation( + const QuoteInfo& quote_info) + { + auto report = get_snp_attestation_report(quote_info); + if (!report.has_value()) + { + return std::nullopt; + } + + if (quote_info.quote.size() != sizeof(pal::snp::Attestation)) + { + LOG_FAIL_FMT( + "Verified SNP report has unexpected size {} (expected {})", + quote_info.quote.size(), + sizeof(pal::snp::Attestation)); + return std::nullopt; + } + + pal::snp::Attestation legacy_report = {}; + std::memcpy( + &legacy_report, quote_info.quote.data(), sizeof(pal::snp::Attestation)); + return legacy_report; + } +#pragma GCC diagnostic pop + std::optional AttestationProvider::get_host_data( const QuoteInfo& quote_info) { @@ -205,13 +230,13 @@ namespace ccf pal::PlatformAttestationReportData r = {}; try { - pal::verify_quote(quote_info, d, r); - auto quote = *reinterpret_cast( - quote_info.quote.data()); - std::copy( - std::begin(quote.host_data), - std::end(quote.host_data), - rep.begin()); + const auto report = + pal::verify_snp_attestation_report_and_get(quote_info, d, r); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_host_data(report.get(), &data, &size); + const auto host_data = std::span{data, size}; + std::copy(host_data.begin(), host_data.end(), rep.begin()); } catch (const std::exception& e) { @@ -279,9 +304,8 @@ namespace ccf pal::PlatformAttestationMeasurement d = {}; pal::PlatformAttestationReportData r = {}; - pal::verify_quote(quote_info, d, r); auto attestation = - *reinterpret_cast(quote_info.quote.data()); + pal::verify_snp_attestation_report_and_get(quote_info, d, r); std::optional min_tcb_opt = std::nullopt; auto* h = tx.ro(Tables::SNP_TCB_VERSIONS); @@ -290,9 +314,12 @@ namespace ccf const std::string& cpuid_hex, const pal::snp::TcbVersionPolicy& v) { auto cpuid = pal::snp::cpuid_from_hex(cpuid_hex); if ( - cpuid.get_family_id() == attestation.cpuid_fam_id && - cpuid.get_model_id() == attestation.cpuid_mod_id && - cpuid.stepping == attestation.cpuid_step) + cpuid.get_family_id() == + tav_snp_attestation_report_cpuid_fam_id(attestation.get()) && + cpuid.get_model_id() == + tav_snp_attestation_report_cpuid_mod_id(attestation.get()) && + cpuid.stepping == + tav_snp_attestation_report_cpuid_step(attestation.get())) { min_tcb_opt = v; return false; @@ -307,9 +334,13 @@ namespace ccf // CPUID of the attested cpu must now be equal to the min_tcb_opt's cpuid auto product_family = pal::snp::get_sev_snp_product( - attestation.cpuid_fam_id, attestation.cpuid_mod_id); + tav_snp_attestation_report_cpuid_fam_id(attestation.get()), + tav_snp_attestation_report_cpuid_mod_id(attestation.get())); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb(attestation.get(), &data, &size); auto attestation_tcb_policy = - attestation.reported_tcb.to_policy(product_family); + pal::snp::TcbVersionRaw({data, size}).to_policy(product_family); if (pal::snp::TcbVersionPolicy::is_valid( min_tcb_opt.value(), attestation_tcb_policy)) diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index ae596eb13906..e51ebc0e3eb6 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -1662,7 +1662,8 @@ namespace ccf ctx.tx, in.snp_uvm_endorsements, recovering); auto attestation = - AttestationProvider::get_snp_attestation(in.quote_info).value(); + AttestationProvider::get_snp_attestation_report(in.quote_info) + .value(); InternalTablesAccess::trust_node_snp_tcb_version( ctx.tx, attestation); break; diff --git a/src/node/rpc/test/internal_tables_access_test.cpp b/src/node/rpc/test/internal_tables_access_test.cpp index 42b90fed27e5..486e0e22e00c 100644 --- a/src/node/rpc/test/internal_tables_access_test.cpp +++ b/src/node/rpc/test/internal_tables_access_test.cpp @@ -89,6 +89,17 @@ TEST_CASE("direct node deletion updates consensus configuration") REQUIRE(consensus.configuration_changes == 1); } +TEST_CASE("trust_node_snp_tcb_version rejects an empty owner") +{ + ccf::kv::Store kv_store; + auto tx = kv_store.create_tx(); + const pal::snp::AttestationReport report; + CHECK_THROWS_WITH_AS( + InternalTablesAccess::trust_node_snp_tcb_version(tx, report), + "Cannot access an empty SNP attestation report", + std::logic_error); +} + TEST_CASE("trust_node_uvm_endorsements - not recovering, empty map") { ccf::kv::Store kv_store; diff --git a/src/pal/attestation.cpp b/src/pal/attestation.cpp index 8df0a1fbea34..29f1e7167c37 100644 --- a/src/pal/attestation.cpp +++ b/src/pal/attestation.cpp @@ -3,9 +3,7 @@ #include "ccf/pal/attestation.h" -#include "ccf/crypto/ecdsa.h" #include "ccf/crypto/openssl/openssl_wrappers.h" -#include "ccf/crypto/verifier.h" #include "ccf/ds/json.h" #include "ccf/pal/attestation_sev_snp.h" #include "ccf/pal/sev_snp_cpuid.h" @@ -16,37 +14,49 @@ namespace ccf::pal { - using Unique_ASN1_OBJECT = ccf::crypto::OpenSSL:: - Unique_SSL_OBJECT; - using Unique_ASN1_INTEGER = ccf::crypto::OpenSSL:: - Unique_SSL_OBJECT; - namespace { - std::string x509_name_to_rfc2253_string(X509_NAME* name) - { - ccf::crypto::OpenSSL::CHECKNULL(name); + using TavErrorPtr = std::unique_ptr; - ccf::crypto::OpenSSL::Unique_BIO mem; - const auto rc = X509_NAME_print_ex(mem, name, 0, XN_FLAG_RFC2253); - if (rc < 0) + void check_tav_error(std::string_view operation, const TavError* error) + { + if (error != nullptr) { - const auto ec = ERR_get_error(); - throw std::runtime_error(fmt::format( - "OpenSSL error (rc={}, ec={}): {}", - rc, - ec, - ccf::crypto::OpenSSL::error_string(ec))); + throw std::logic_error(fmt::format( + "SEV-SNP: TAV {} failed ({}): {}", + operation, + static_cast(tav_error_code(error)), + tav_error_message(error))); } + } + } - BUF_MEM* bptr = nullptr; - ccf::crypto::OpenSSL::CHECK1(BIO_get_mem_ptr(mem, &bptr)); - ccf::crypto::OpenSSL::CHECKNULL(bptr); - - return {bptr->data, bptr->length}; + namespace snp + { + AttestationReport parse_attestation_report_unverified( + std::span report) + { + TavSnpAttestationReport* raw_report = nullptr; + TavErrorPtr error( + tav_snp_attestation_report_from_unverified_bytes( + report.data(), report.size(), &raw_report), + tav_error_free); + AttestationReport parsed_report(raw_report); + check_tav_error("unverified report parsing", error.get()); + if (parsed_report == nullptr) + { + throw std::logic_error( + "SEV-SNP: TAV parsing succeeded without returning a report"); + } + return parsed_report; } } + using Unique_ASN1_OBJECT = ccf::crypto::OpenSSL:: + Unique_SSL_OBJECT; + using Unique_ASN1_INTEGER = ccf::crypto::OpenSSL:: + Unique_SSL_OBJECT; + void verify_virtual_attestation_report( const QuoteInfo& quote_info, PlatformAttestationMeasurement& measurement, @@ -235,7 +245,7 @@ namespace ccf::pal } // Verifying SNP attestation report is available on all platforms. - void verify_snp_attestation_report( + snp::AttestationReport verify_snp_attestation_report_and_get( const QuoteInfo& quote_info, PlatformAttestationMeasurement& measurement, PlatformAttestationReportData& report_data) @@ -247,33 +257,20 @@ namespace ccf::pal quote_info.format)); } - if (quote_info.quote.size() != sizeof(snp::Attestation)) + const auto& report = quote_info.quote; + const auto& endorsements = quote_info.endorsements; + if (report.size() != snp::attestation_report_size) { throw std::logic_error(fmt::format( "Input SEV-SNP attestation report is not of expected size {}: {}", - sizeof(snp::Attestation), - quote_info.quote.size())); + snp::attestation_report_size, + report.size())); } - auto quote = - *reinterpret_cast(quote_info.quote.data()); - - if (quote.version < snp::minimum_attestation_version) - { - throw std::logic_error(fmt::format( - "SEV-SNP: Attestation version is {} not >= expected minimum {}", - quote.version, - snp::minimum_attestation_version)); - } - - auto product_family = - snp::get_sev_snp_product(quote.cpuid_fam_id, quote.cpuid_mod_id); - // ---- Verify certificate chain ---- auto certificates = ccf::crypto::split_x509_cert_bundle(std::string_view( - reinterpret_cast(quote_info.endorsements.data()), - quote_info.endorsements.size())); + reinterpret_cast(endorsements.data()), endorsements.size())); if (certificates.size() != 3) { throw std::logic_error(fmt::format( @@ -286,97 +283,54 @@ namespace ccf::pal auto ask_cert = certificates[1]; auto ark_cert = certificates[2]; - auto ark_verifier = ccf::crypto::make_verifier(ark_cert); - - auto key = snp::amd_root_signing_keys.find(product_family); - if (key == snp::amd_root_signing_keys.end()) - { - throw std::logic_error(fmt::format( - "SEV-SNP: No known root certificate for {}", product_family)); - } - const auto& expected_ark = key->second; - if (ark_verifier->public_key_pem().str() != expected_ark.public_key) - { - throw std::logic_error(fmt::format( - "SEV-SNP: The root of trust public key for this attestation was not " - "the expected one for v{} {} {}: {} != {}", - quote.version, - quote.cpuid_fam_id, - quote.cpuid_mod_id, - ark_verifier->public_key_pem().str(), - expected_ark.public_key)); - } - - ccf::crypto::OpenSSL::Unique_BIO mem_bio(ark_cert); - ccf::crypto::OpenSSL::Unique_X509 x509( - mem_bio, true, true /* check_null */); - const auto issuer = x509_name_to_rfc2253_string(X509_get_issuer_name(x509)); - if (issuer != expected_ark.issuer) - { - throw std::logic_error(fmt::format( - "SEV-SNP: The root of trust issuer for this attestation was not " - "the expected one for {}: {} != {}", - product_family, - issuer, - expected_ark.issuer)); - } - - if (!ark_verifier->verify_certificate({&ark_cert})) - { - throw std::logic_error( - "SEV-SNP: The root of trust public key for this attestation was not " - "self signed as expected"); - } - - auto vcek_verifier = ccf::crypto::make_verifier(/* leaf */ vcek_cert); - if (!vcek_verifier->verify_certificate( - /* root */ {&ark_cert}, /* chain */ {&ask_cert})) + TavSnpAttestationReport* raw_report = nullptr; + TavErrorPtr error( + tav_verify_snp_attestation( + report.data(), + report.size(), + ark_cert.data(), + ark_cert.size(), + ask_cert.data(), + ask_cert.size(), + vcek_cert.data(), + vcek_cert.size(), + &raw_report), + tav_error_free); + snp::AttestationReport attestation(raw_report); + check_tav_error("verification", error.get()); + if (attestation == nullptr) { throw std::logic_error( - "SEV-SNP: The chain of signatures from the root of trust to this " - "attestation is broken"); + "SEV-SNP: TAV verification succeeded without returning a report"); } - // ---- Verify attestation report signature ---- - - // According to Table 134 (2025-06-12) only ecdsa_p384_sha384 is supported - if (quote.signature_algo != snp::SignatureAlgorithm::ecdsa_p384_sha384) + if ( + tav_snp_attestation_report_version(attestation.get()) < + snp::minimum_attestation_version) { throw std::logic_error(fmt::format( - "SEV-SNP: Unsupported signature algorithm: {} (supported: {})", - quote.signature_algo, - snp::SignatureAlgorithm::ecdsa_p384_sha384)); + "SEV-SNP: Attestation version is {} not >= expected minimum {}", + tav_snp_attestation_report_version(attestation.get()), + snp::minimum_attestation_version)); } - // Make ASN1 DER signature - auto quote_signature = ccf::crypto::ecdsa_sig_from_r_s( - quote.signature.r, - sizeof(quote.signature.r), - quote.signature.s, - sizeof(quote.signature.s), - false /* little endian */ - ); - - std::span quote_without_signature{ - quote_info.quote.data(), - quote_info.quote.size() - sizeof(quote.signature)}; - if (!vcek_verifier->verify(quote_without_signature, quote_signature)) - { - throw std::logic_error( - "SEV-SNP: Chip certificate (VCEK) did not sign this attestation"); - } + const auto product_family = snp::get_sev_snp_product( + tav_snp_attestation_report_cpuid_fam_id(attestation.get()), + tav_snp_attestation_report_cpuid_mod_id(attestation.get())); // ---- Verify attestation report contents ---- - if (quote.flags.signing_key != snp::attestation_flags_signing_key_vcek) + if ( + tav_snp_attestation_report_flags_signing_key(attestation.get()) != + snp::attestation_flags_signing_key_vcek) { throw std::logic_error(fmt::format( "SEV-SNP: Attestation report must be signed by VCEK: {}", - static_cast(quote.flags.signing_key))); + tav_snp_attestation_report_flags_signing_key(attestation.get()))); } // mask_chip_key if set means the operator set the vcek to 0s - if (quote.flags.mask_chip_key != 0) + if (tav_snp_attestation_report_flags_mask_chip_key(attestation.get())) { throw std::logic_error( fmt::format("SEV-SNP: Mask chip key must not be set")); @@ -385,15 +339,15 @@ namespace ccf::pal // All attestation reports generated by guests must have VMPL <= 3 // while host generated reports have VMPL > 3. // We should reject host generated reports. - if (quote.vmpl > 3) + if (tav_snp_attestation_report_vmpl(attestation.get()) > 3) { throw std::logic_error(fmt::format( "SEV-SNP: This report seems to be host generated (VMPL {} > 3)", - quote.vmpl)); + tav_snp_attestation_report_vmpl(attestation.get()))); } // Debug mode would allow decryption of guest pages - if (quote.policy.debug != 0) + if (tav_snp_attestation_report_policy_debug(attestation.get())) { throw std::logic_error( "SEV-SNP: SNP attestation report guest policy debugging must not be " @@ -402,18 +356,25 @@ namespace ccf::pal // Migration of CCF nodes and other services could allow duplicates, and // hence must be disallowed - if (quote.policy.migrate_ma != 0) + if (tav_snp_attestation_report_policy_migrate_ma(attestation.get())) { throw std::logic_error( "SEV-SNP: SNP attestation report guest policy migration must not be " "enabled"); } + const uint8_t* reported_tcb_data = nullptr; + size_t reported_tcb_size = 0; + tav_snp_attestation_report_reported_tcb( + attestation.get(), &reported_tcb_data, &reported_tcb_size); + const auto reported_tcb_raw = + std::span{reported_tcb_data, reported_tcb_size}; auto endorsed_tcb = get_endorsed_tcb_from_cert(product_family, vcek_cert); if (endorsed_tcb.has_value()) { auto endorsed_tcb_policy = endorsed_tcb->to_policy(product_family); - auto reported_tcb = quote.reported_tcb.to_policy(product_family); + auto reported_tcb = + snp::TcbVersionRaw(reported_tcb_raw).to_policy(product_family); if (!snp::TcbVersionPolicy::is_valid(endorsed_tcb_policy, reported_tcb)) { @@ -426,7 +387,7 @@ namespace ccf::pal } auto endorsed_chip_id = get_endorsed_chip_id_from_cert(vcek_cert); - auto reported_chip_id = quote.get_chip_id_for_vcek(); + auto reported_chip_id = snp::get_chip_id_for_vcek(attestation); if ( endorsed_chip_id.has_value() && (endorsed_chip_id->size() != reported_chip_id.size() || @@ -444,13 +405,14 @@ namespace ccf::pal if (quote_info.endorsed_tcb.has_value()) { - const auto& quote_endorsed_tcb = quote_info.endorsed_tcb.value(); - auto raw_endorsed_tcb = snp::TcbVersionRaw::from_hex(quote_endorsed_tcb); + auto raw_endorsed_tcb = + snp::TcbVersionRaw::from_hex(quote_info.endorsed_tcb.value()); - if (raw_endorsed_tcb != quote.reported_tcb) + const auto reported_tcb = snp::TcbVersionRaw(reported_tcb_raw); + if (raw_endorsed_tcb != reported_tcb) { auto endorsed_tcb_hex = raw_endorsed_tcb.to_hex(); - auto report_tcb_hex = quote.reported_tcb.to_hex(); + auto report_tcb_hex = reported_tcb.to_hex(); throw std::logic_error(fmt::format( "SEV-SNP: endorsed TCB {} does not match reported TCB {}", endorsed_tcb_hex, @@ -460,8 +422,21 @@ namespace ccf::pal // ---- Set return values ---- - report_data = SnpAttestationReportData(quote.report_data); - measurement = SnpAttestationMeasurement(quote.measurement); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_data(attestation.get(), &data, &size); + report_data = SnpAttestationReportData({data, size}); + tav_snp_attestation_report_measurement(attestation.get(), &data, &size); + measurement = SnpAttestationMeasurement({data, size}); + return attestation; + } + + void verify_snp_attestation_report( + const QuoteInfo& quote_info, + PlatformAttestationMeasurement& measurement, + PlatformAttestationReportData& report_data) + { + verify_snp_attestation_report_and_get(quote_info, measurement, report_data); } void verify_quote( diff --git a/src/pal/quote_generation.h b/src/pal/quote_generation.h index 72ec00b8ddea..4c35101ad437 100644 --- a/src/pal/quote_generation.h +++ b/src/pal/quote_generation.h @@ -89,25 +89,27 @@ namespace ccf::pal { QuoteInfo node_quote_info = {}; node_quote_info.format = QuoteFormat::amd_sev_snp_v1; - auto attestation = snp::get_attestation(report_data); + node_quote_info.quote = snp::get_attestation(report_data)->get_raw(); + auto report = + snp::parse_attestation_report_unverified(node_quote_info.quote); - if (attestation->get().version < pal::snp::minimum_attestation_version) + if ( + tav_snp_attestation_report_version(report.get()) < + pal::snp::minimum_attestation_version) { throw std::logic_error(fmt::format( "SEV-SNP: attestation version {} is less than the minimum supported " "version {}", - attestation->get().version, + tav_snp_attestation_report_version(report.get()), pal::snp::minimum_attestation_version)); } - node_quote_info.quote = attestation->get_raw(); - if (endorsement_cb != nullptr) { endorsement_cb( node_quote_info, snp::make_endorsement_endpoint_configuration( - attestation->get(), endorsements_servers)); + report, endorsements_servers)); } } diff --git a/src/pal/test/snp_attestation_validation.cpp b/src/pal/test/snp_attestation_validation.cpp index afc8d026418e..6cd2aa48eee2 100644 --- a/src/pal/test/snp_attestation_validation.cpp +++ b/src/pal/test/snp_attestation_validation.cpp @@ -6,12 +6,14 @@ #include "ccf/ds/hex.h" #include "ccf/ds/logger.h" #include "ccf/ds/quote_info.h" +#include "ccf/node/quote.h" #include "ccf/pal/attestation.h" #include "ccf/pal/attestation_sev_snp.h" #include "ccf/pal/attestation_sev_snp_endorsements.h" #include "ccf/pal/measurement.h" #include "ccf/pal/report_data.h" #include "ccf/pal/sev_snp_cpuid.h" +#include "ccf/pal/snp_ioctl.h" #include "crypto/openssl/hash.h" #include "pal/test/attestation.h" #include "pal/test/attestation_sev_snp_endorsements.h" @@ -20,6 +22,7 @@ #include #include #include +#include #define DOCTEST_CONFIG_IMPLEMENT #include @@ -128,6 +131,270 @@ namespace } } +TEST_CASE("CCF policy is separate from generic TAV verification") +{ + using namespace ccf::pal; + const auto certs = milan_endorsement_certs(); + REQUIRE(certs.size() == 3); + TavSnpAttestationReport* raw_report = nullptr; + const std::unique_ptr error( + tav_verify_snp_attestation( + snp::testing::milan_attestation.data(), + snp::testing::milan_attestation.size(), + certs[2].data(), + certs[2].size(), + certs[1].data(), + certs[1].size(), + certs[0].data(), + certs[0].size(), + &raw_report), + tav_error_free); + const snp::AttestationReport report(raw_report); + REQUIRE(error == nullptr); + REQUIRE(report != nullptr); + CHECK( + tav_snp_attestation_report_version(report.get()) == + snp::minimum_attestation_version); + + PlatformAttestationMeasurement measurement; + PlatformAttestationReportData report_data; + const std::vector endorsements( + snp::testing::milan_endorsements.begin(), + snp::testing::milan_endorsements.end()); + const ccf::QuoteInfo quote_info = { + .format = ccf::QuoteFormat::amd_sev_snp_v1, + .quote = snp::testing::milan_attestation, + .endorsements = endorsements, + .uvm_endorsements = std::nullopt, + .endorsed_tcb = "0000000000000000"}; + CHECK_THROWS_WITH_AS( + verify_snp_attestation_report_and_get(quote_info, measurement, report_data), + doctest::Contains("does not match reported TCB"), + std::logic_error); +} + +TEST_CASE("unverified SNP report rejects invalid sizes") +{ + for (const size_t size : {0U, 100U, 1183U, 1185U}) + { + const auto expected_error = size == 0 ? + "SEV-SNP: TAV unverified report parsing failed (1): attestation report " + "is empty" : + fmt::format( + "SEV-SNP: TAV unverified report parsing failed (1): Invalid " + "attestation report: expected 1184 bytes, got {}", + size); + CHECK_THROWS_WITH_AS( + static_cast(ccf::pal::snp::parse_attestation_report_unverified( + std::vector(size))), + expected_error.c_str(), + std::logic_error); + } +} + +TEST_CASE("SNP chip ID access rejects empty handles") +{ + ccf::pal::snp::AttestationReport report; + CHECK_THROWS_WITH_AS( + ccf::pal::snp::get_chip_id_for_vcek(report), + "Cannot access an empty SNP attestation report", + std::logic_error); +} + +TEST_CASE("SNP endorsement configuration rejects empty owners") +{ + using namespace ccf::pal::snp; + AttestationReport report; + + SUBCASE("default constructed") {} + + SUBCASE("moved from") + { + report = parse_attestation_report_unverified(testing::milan_attestation); + auto owner = std::move(report); + REQUIRE(owner != nullptr); + CHECK_NOTHROW(make_endorsement_endpoint_configuration(owner)); + } + + REQUIRE(report == nullptr); + CHECK_THROWS_WITH_AS( + make_endorsement_endpoint_configuration(report), + "Cannot access an empty SNP attestation report", + std::logic_error); +} + +TEST_CASE("VCEK chip ID uses the product-specific prefix") +{ + using namespace ccf::pal::snp; + struct TestCase + { + const std::vector& report; + size_t chip_id_size; + }; + for (const auto& [raw_report, expected_size] : + {TestCase{testing::milan_attestation, 64}, + TestCase{testing::genoa_attestation, 64}, + TestCase{testing::turin_attestation, 8}}) + { + auto report = parse_attestation_report_unverified(raw_report); + const auto vcek_chip_id = get_chip_id_for_vcek(report); + REQUIRE(vcek_chip_id.size() == expected_size); + CHECK(std::equal( + vcek_chip_id.begin(), vcek_chip_id.end(), raw_report.begin() + 0x1A0)); + } +} + +TEST_CASE("TCB values can be constructed from borrowed bytes") +{ + using ccf::pal::snp::TcbVersionRaw; + std::array bytes = {4, 0, 0, 0, 0, 0, 24, 219}; + const auto tcb = TcbVersionRaw(std::span(bytes)); + CHECK(tcb.to_hex() == "db18000000000004"); + CHECK(tcb == TcbVersionRaw(std::vector(bytes.begin(), bytes.end()))); + bytes.fill(0); + CHECK(tcb.to_hex() == "db18000000000004"); + for (const size_t size : {0, 7, 9}) + { + const std::vector invalid_bytes(size); + const auto expected_error = + fmt::format("Invalid TCB version raw data size: {}", size); + CHECK_THROWS_WITH_AS( + TcbVersionRaw{invalid_bytes}, expected_error.c_str(), std::logic_error); + CHECK_THROWS_WITH_AS( + TcbVersionRaw(std::span(invalid_bytes)), + expected_error.c_str(), + std::logic_error); + } +} + +TEST_CASE("SNP verification preserves invalid size error") +{ + ccf::pal::PlatformAttestationMeasurement measurement; + ccf::pal::PlatformAttestationReportData report_data; + const ccf::QuoteInfo quote_info = { + .format = ccf::QuoteFormat::amd_sev_snp_v1, + .quote = std::vector(100), + .endorsements = {}, + .uvm_endorsements = std::nullopt}; + CHECK_THROWS_WITH_AS( + ccf::pal::verify_snp_attestation_report_and_get( + quote_info, measurement, report_data), + doctest::Contains( + "Input SEV-SNP attestation report is not of expected size 1184: 100"), + std::logic_error); +} + +TEST_CASE("SNP verification rejects other quote formats before parsing") +{ + for (const auto format : + {ccf::QuoteFormat::insecure_virtual, ccf::QuoteFormat::oe_sgx_v1}) + { + const ccf::QuoteInfo quote_info = { + .format = format, + .quote = {}, + .endorsements = {}, + .uvm_endorsements = std::nullopt}; + ccf::pal::PlatformAttestationMeasurement measurement; + ccf::pal::PlatformAttestationReportData report_data; + const auto expected_error = fmt::format( + "Unexpected attestation report to verify for SEV-SNP: {}", format); + CHECK_THROWS_WITH_AS( + ccf::pal::verify_snp_attestation_report_and_get( + quote_info, measurement, report_data), + expected_error.c_str(), + std::logic_error); + CHECK_THROWS_WITH_AS( + ccf::pal::verify_snp_attestation_report( + quote_info, measurement, report_data), + expected_error.c_str(), + std::logic_error); + } +} + +TEST_CASE("SNP request rejects oversized report data before ioctl") +{ + ccf::pal::PlatformAttestationReportData report_data; + report_data.data.resize(ccf::pal::snp_attestation_report_data_size + 1); + CHECK_THROWS_WITH_AS( + ccf::pal::snp::ioctl6::Attestation{report_data}, + "User-defined report data is larger than available space", + std::logic_error); +} + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +TEST_CASE("legacy SNP report layout matches the AMD specification") +{ + using ccf::pal::snp::Attestation; + + static_assert( + std::is_same_v< + decltype(std::declval().get()), + const Attestation&>); + static_assert( + std::is_same_v< + decltype(std::declval().get_raw()), + std::vector>); + static_assert( + std::is_same_v< + decltype(std::declval().get_raw()), + std::vector>); + static_assert(std::is_same_v< + decltype(ccf::AttestationProvider::get_snp_attestation( + std::declval())), + std::optional>); + static_assert(std::is_same_v< + decltype(ccf::pal::snp::ioctl6::AttestationResp::report), + Attestation>); + + CHECK(ccf::pal::snp::amd_root_signing_keys.size() == 3); + CHECK( + ccf::pal::snp::amd_root_signing_keys.at(ccf::pal::snp::ProductName::Milan) + .public_key == ccf::pal::snp::amd_milan_root_signing_public_key); + + Attestation report = {}; + CHECK(sizeof(report) == ccf::pal::snp::attestation_report_size); + CHECK(alignof(Attestation) == 1); + CHECK(offsetof(Attestation, version) == 0x000); + CHECK(offsetof(Attestation, policy) == 0x008); + CHECK(offsetof(Attestation, report_data) == 0x050); + CHECK(offsetof(Attestation, measurement) == 0x090); + CHECK(offsetof(Attestation, reported_tcb) == 0x180); + CHECK(offsetof(Attestation, chip_id) == 0x1A0); + CHECK(offsetof(Attestation, current_build) == 0x1E8); + CHECK(offsetof(Attestation, current_minor) == 0x1E9); + CHECK(offsetof(Attestation, current_major) == 0x1EA); + CHECK(offsetof(Attestation, signature) == 0x2A0); + + ccf::pal::snp::ioctl6::detail::AttestationResponse response; + response.report_size = ccf::pal::snp::attestation_report_size; + std::copy( + ccf::pal::snp::testing::milan_attestation.begin(), + ccf::pal::snp::testing::milan_attestation.end(), + response.report_bytes.begin()); + ccf::pal::snp::ioctl6::PaddedAttestationResp legacy_response; + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(legacy_response) == sizeof(response)); + std::memcpy(&legacy_response, &response, sizeof(legacy_response)); + CHECK(legacy_response.report_size == response.report_size); + REQUIRE(response.report_bytes[0x1E8] != response.report_bytes[0x1E9]); + CHECK(legacy_response.report.current_build == response.report_bytes[0x1E8]); + CHECK(legacy_response.report.current_minor == response.report_bytes[0x1E9]); + CHECK( + std::memcmp( + &legacy_response.report, + response.report_bytes.data(), + response.report_bytes.size()) == 0); + + report.version = ccf::pal::snp::minimum_attestation_version; + report.cpuid_fam_id = 0x19; + report.cpuid_mod_id = 0x01; + const auto config = + ccf::pal::snp::make_endorsement_endpoint_configuration(report); + CHECK(config.servers.size() == 1); +} +#pragma clang diagnostic pop + TEST_CASE("milan validation") { using namespace ccf; @@ -144,8 +411,15 @@ TEST_CASE("milan validation") pal::PlatformAttestationMeasurement measurement; pal::PlatformAttestationReportData report_data; + const auto report = pal::verify_snp_attestation_report_and_get( + milan_quote_info, measurement, report_data); + REQUIRE(report != nullptr); + const auto verified_measurement = measurement.data; + const auto verified_report_data = report_data.data; pal::verify_snp_attestation_report( milan_quote_info, measurement, report_data); + CHECK(measurement.data == verified_measurement); + CHECK(report_data.data == verified_report_data); } TEST_CASE("genoa validation") @@ -188,6 +462,31 @@ TEST_CASE("turin validation") turin_quote_info, measurement, report_data); } +TEST_CASE("Invalid attestation signature fails TAV verification") +{ + using namespace ccf; + + auto invalid_attestation = pal::snp::testing::milan_attestation; + static constexpr size_t signature_offset = 0x2a0; + invalid_attestation[signature_offset] ^= 1; + auto quote_info = QuoteInfo{ + .format = QuoteFormat::amd_sev_snp_v1, + .quote = std::move(invalid_attestation), + .endorsements = std::vector( + pal::snp::testing::milan_endorsements.begin(), + pal::snp::testing::milan_endorsements.end()), + .uvm_endorsements = std::nullopt, + }; + + pal::PlatformAttestationMeasurement measurement; + pal::PlatformAttestationReportData report_data; + + CHECK_THROWS_WITH_AS( + pal::verify_snp_attestation_report(quote_info, measurement, report_data), + doctest::Contains("SEV-SNP: TAV verification failed (104):"), + std::logic_error); +} + TEST_CASE("Mismatched attestation and endorsements fail") { using namespace ccf; @@ -207,9 +506,7 @@ TEST_CASE("Mismatched attestation and endorsements fail") CHECK_THROWS_WITH_AS( pal::verify_snp_attestation_report( mismatched_quote, measurement, report_data), - doctest::Contains( - "SEV-SNP: The root of trust public key for this attestation " - "was not the expected one"), + doctest::Contains("SEV-SNP: TAV verification failed (102):"), std::logic_error); } @@ -223,9 +520,7 @@ TEST_CASE("ARK with unexpected issuer fails") CHECK_THROWS_WITH_AS( ccf::pal::verify_snp_attestation_report( quote_info, measurement, report_data), - doctest::Contains( - "SEV-SNP: The root of trust issuer for this attestation was not " - "the expected one"), + doctest::Contains("SEV-SNP: TAV verification failed (102):"), std::logic_error); } @@ -269,11 +564,15 @@ TEST_CASE("Parsing of Tcb versions from strings") TEST_CASE("Parsing tcb versions from attestaion") { - auto milan_attestation = *reinterpret_cast( - ccf::pal::snp::testing::milan_attestation.data()); - auto milan_tcb = - milan_attestation.reported_tcb.to_policy(ccf::pal::snp::ProductName::Milan) - .to_milan_genoa(); + auto milan_attestation = ccf::pal::snp::parse_attestation_report_unverified( + ccf::pal::snp::testing::milan_attestation); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + milan_attestation.get(), &data, &size); + auto milan_tcb = ccf::pal::snp::TcbVersionRaw({data, size}) + .to_policy(ccf::pal::snp::ProductName::Milan) + .to_milan_genoa(); CHECK_EQ(milan_tcb.microcode, 0xdb); CHECK_EQ(milan_tcb.snp, 0x18); CHECK_EQ(milan_tcb.tee, 0x00); @@ -488,7 +787,7 @@ TEST_CASE("Quote endorsements url generation") for (auto [attestation, servers, expected_url] : test_cases) { auto quote = - *reinterpret_cast(attestation.data()); + ccf::pal::snp::parse_attestation_report_unverified(attestation); auto config = ccf::pal::snp::make_endorsement_endpoint_configuration(quote, servers); @@ -496,15 +795,52 @@ TEST_CASE("Quote endorsements url generation") } } +TEST_CASE("Quote endorsement TCB formatting preserves leading zeroes") +{ + using namespace ccf::pal::snp; + + for (const auto& expected_tcb : + {"0000000000000000", "0001000000000004", "0b18000000000004"}) + { + auto report = testing::milan_attestation; + const auto tcb_bytes = ccf::ds::from_hex(expected_tcb); + std::reverse_copy( + tcb_bytes.begin(), tcb_bytes.end(), report.begin() + 0x180); + auto quote = parse_attestation_report_unverified(report); + + const auto default_config = make_endorsement_endpoint_configuration(quote); + REQUIRE_EQ(default_config.servers.size(), 1); + REQUIRE_EQ(default_config.servers.front().size(), 1); + CHECK(default_config.servers.front().front().uri.ends_with( + std::string("/") + expected_tcb)); + + const auto config = make_endorsement_endpoint_configuration( + quote, + {{EndorsementsEndpointType::Azure}, {EndorsementsEndpointType::THIM}}); + REQUIRE_EQ(config.servers.size(), 2); + REQUIRE_EQ(config.servers.front().size(), 1); + REQUIRE_EQ(config.servers.back().size(), 1); + CHECK(config.servers.front().front().uri.ends_with( + std::string("/") + expected_tcb)); + CHECK_EQ( + config.servers.back().front().params.at("tcbVersion"), expected_tcb); + } +} + TEST_CASE("Quote endorsements generation for v2 attestation version fails") { auto v2_format_milan_attestation = - *reinterpret_cast( - ccf::pal::snp::testing::v2_format_milan_attestation.data()); + ccf::pal::snp::parse_attestation_report_unverified( + ccf::pal::snp::testing::v2_format_milan_attestation); - CHECK_EQ(v2_format_milan_attestation.version, 2); - CHECK_EQ(v2_format_milan_attestation.cpuid_fam_id, 0x0); - CHECK_EQ(v2_format_milan_attestation.cpuid_mod_id, 0x0); + CHECK_EQ( + tav_snp_attestation_report_version(v2_format_milan_attestation.get()), 2); + CHECK_EQ( + tav_snp_attestation_report_cpuid_fam_id(v2_format_milan_attestation.get()), + 0x0); + CHECK_EQ( + tav_snp_attestation_report_cpuid_mod_id(v2_format_milan_attestation.get()), + 0x0); CHECK_THROWS_WITH( ccf::pal::snp::make_endorsement_endpoint_configuration( @@ -530,8 +866,8 @@ TEST_CASE("Extracting metadata from endorsements") .uvm_endorsements = std::nullopt, }; - auto attestation = *reinterpret_cast( - milan_quote_info.quote.data()); + auto attestation = + pal::snp::parse_attestation_report_unverified(milan_quote_info.quote); auto certificates = ccf::crypto::split_x509_cert_bundle(std::string_view( reinterpret_cast(milan_quote_info.endorsements.data()), @@ -542,14 +878,17 @@ TEST_CASE("Extracting metadata from endorsements") auto endorsed_tcb = pal::get_endorsed_tcb_from_cert( pal::snp::ProductName::Milan, chip_certificate); REQUIRE(endorsed_tcb.has_value()); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb(attestation.get(), &data, &size); CHECK_EQ( nlohmann::json(endorsed_tcb.value()).dump(), - nlohmann::json(attestation.reported_tcb).dump()); + nlohmann::json(pal::snp::TcbVersionRaw({data, size})).dump()); auto endorsed_chip_id = pal::get_endorsed_chip_id_from_cert(chip_certificate); REQUIRE(endorsed_chip_id.has_value()); - auto printable_reported_chip_id = std::span( - attestation.chip_id, attestation.chip_id + sizeof(attestation.chip_id)); + tav_snp_attestation_report_chip_id(attestation.get(), &data, &size); + const auto printable_reported_chip_id = std::span{data, size}; CHECK_EQ( ds::to_hex(endorsed_chip_id.value()), ds::to_hex(printable_reported_chip_id)); diff --git a/src/pal/test/snp_ioctl_test.cpp b/src/pal/test/snp_ioctl_test.cpp index f873733d173e..2cd0d3779f15 100644 --- a/src/pal/test/snp_ioctl_test.cpp +++ b/src/pal/test/snp_ioctl_test.cpp @@ -23,11 +23,14 @@ TEST_CASE("SNP request attestation") snp_report_data.report_data.begin(), snp_report_data.report_data.end(), 0); PlatformAttestationReportData report_data(snp_report_data); - snp::ioctl6::Attestation ioctl_attestation(report_data); - - const snp::Attestation& attestation = ioctl_attestation.get(); - - SnpAttestationReportData attested_report_data(attestation.report_data); + const auto attestation = snp::get_attestation(report_data)->get_raw(); + REQUIRE(attestation.size() == snp::attestation_report_size); + const auto report = snp::parse_attestation_report_unverified(attestation); + + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_data(report.get(), &data, &size); + SnpAttestationReportData attested_report_data({data, size}); REQUIRE_EQ(snp_report_data.report_data, attested_report_data.report_data); } diff --git a/src/pal/test/verify_attestation.cpp b/src/pal/test/verify_attestation.cpp index 660380739b1b..ef99ab5f730e 100644 --- a/src/pal/test/verify_attestation.cpp +++ b/src/pal/test/verify_attestation.cpp @@ -18,8 +18,8 @@ void fetch_endorsements( const std::vector& attestation_raw, std::vector& output) { - auto attestation = *reinterpret_cast( - attestation_raw.data()); + auto attestation = + ccf::pal::snp::parse_attestation_report_unverified(attestation_raw); auto endorsement_config = ccf::pal::snp::make_endorsement_endpoint_configuration( @@ -64,15 +64,16 @@ int main(int argc, char** argv) .add_option( "-a,--attestation", attestation_hex, "Attestation in hex format") ->check([](const std::string& attestation_hex) { - auto attest = ccf::ds::from_hex(attestation_hex); - if (attest.size() != sizeof(ccf::pal::snp::Attestation)) + try { - return std::string(fmt::format( - "Attestation size is incorrect {} != {}", - attest.size(), - sizeof(ccf::pal::snp::Attestation))); + static_cast(ccf::pal::snp::parse_attestation_report_unverified( + ccf::ds::from_hex(attestation_hex))); + return std::string(); + } + catch (const std::exception& e) + { + return std::string(e.what()); } - return std::string(); }); ccf::LoggerLevel log_level = ccf::LoggerLevel::INFO; diff --git a/src/pal/test/verify_uvm_attestation_and_endorsements.cpp b/src/pal/test/verify_uvm_attestation_and_endorsements.cpp index f4822e7ce583..aa9c981ead73 100644 --- a/src/pal/test/verify_uvm_attestation_and_endorsements.cpp +++ b/src/pal/test/verify_uvm_attestation_and_endorsements.cpp @@ -241,12 +241,15 @@ int main(int argc, char** argv) "Expected SNP quote format"); LOG_INFO_FMT("Verifying endorsements"); - const auto* attestation_unverified = - reinterpret_cast( - quote_info.quote.data()); + const auto attestation_unverified = + ccf::pal::snp::parse_attestation_report_unverified(quote_info.quote); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + attestation_unverified.get(), &data, &size); validate_endorsements( endorsements, - attestation_unverified->reported_tcb, + ccf::pal::snp::TcbVersionRaw({data, size}), quote_info.endorsements); LOG_INFO_FMT("Verifying quote"); diff --git a/src/service/internal_tables_access.h b/src/service/internal_tables_access.h index f332af2a16b0..7e6e3cae63e1 100644 --- a/src/service/internal_tables_access.h +++ b/src/service/internal_tables_access.h @@ -964,35 +964,49 @@ namespace ccf } static void trust_node_snp_tcb_version( - ccf::kv::Tx& tx, pal::snp::Attestation& attestation) + ccf::kv::Tx& tx, const pal::snp::AttestationReport& attestation) { - if (attestation.version < pal::snp::minimum_attestation_version) + if (attestation == nullptr) + { + throw std::logic_error("Cannot access an empty SNP attestation report"); + } + if ( + tav_snp_attestation_report_version(attestation.get()) < + pal::snp::minimum_attestation_version) { throw std::logic_error(fmt::format( "SEV-SNP: attestation version {} is not supported. Minimum " "supported version is {}", - attestation.version, + tav_snp_attestation_report_version(attestation.get()), pal::snp::minimum_attestation_version)); } // As cpuid -> attestation cpuid is surjective, we must use the local // cpuid and validate it against the attestation's cpuid auto cpuid = pal::snp::get_cpuid_untrusted(); if ( - cpuid.get_family_id() != attestation.cpuid_fam_id || - cpuid.get_model_id() != attestation.cpuid_mod_id || - cpuid.stepping != attestation.cpuid_step) + cpuid.get_family_id() != + tav_snp_attestation_report_cpuid_fam_id(attestation.get()) || + cpuid.get_model_id() != + tav_snp_attestation_report_cpuid_mod_id(attestation.get()) || + cpuid.stepping != + tav_snp_attestation_report_cpuid_step(attestation.get())) { throw std::runtime_error(fmt::format( "CPU-sourced cpuid does not match attestation cpuid ({} != {}, {}, " "{})", cpuid.hex_str(), - attestation.cpuid_fam_id, - attestation.cpuid_mod_id, - attestation.cpuid_step)); + tav_snp_attestation_report_cpuid_fam_id(attestation.get()), + tav_snp_attestation_report_cpuid_mod_id(attestation.get()), + tav_snp_attestation_report_cpuid_step(attestation.get()))); } auto* h = tx.wo(Tables::SNP_TCB_VERSIONS); auto product = pal::snp::get_sev_snp_product(cpuid); - h->put(cpuid.hex_str(), attestation.reported_tcb.to_policy(product)); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb(attestation.get(), &data, &size); + h->put( + cpuid.hex_str(), + pal::snp::TcbVersionRaw({data, size}).to_policy(product)); } static void init_configuration( diff --git a/tests/npm_tests.py b/tests/npm_tests.py index ff7877d09dc5..9311c7c8df86 100644 --- a/tests/npm_tests.py +++ b/tests/npm_tests.py @@ -746,6 +746,12 @@ def test_npm_app(network, args): assert r.status_code == http.HTTPStatus.OK, r.status_code report_json = r.body.json()["attestation"] print(f"{report_json=}") + raw_report = b64decode(reference_quote["raw"]) + expected_current_build = raw_report[0x1E8] + expected_current_minor = raw_report[0x1E9] + assert expected_current_build != expected_current_minor + assert report_json["current_build"] == expected_current_build + assert report_json["current_minor"] == expected_current_minor assert report_json[ "report_data" ] == "7a6a68c0a2b85b8aae00ca04f644831680222f44167e5558a9e072b70c60e958" + ( From b9c429bb3467242e60d3e19dbe947cd251463a30 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Fri, 11 Sep 2026 21:42:49 +0100 Subject: [PATCH 13/15] Avoid races when copying ledger chunks in cleanup tests (#8343) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: cjen1-msft --- tests/e2e_operations.py | 63 +++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/tests/e2e_operations.py b/tests/e2e_operations.py index 67006f397322..3e1a369637f1 100644 --- a/tests/e2e_operations.py +++ b/tests/e2e_operations.py @@ -4314,6 +4314,30 @@ def run_backup_snapshot_cleanup(const_args): test_backup_snapshot_cleanup(network, args) +def copy_ledger_chunk_to_read_only_dir(src, dst, mutate=None): + """ + Copy a ledger chunk into a read-only ledger directory without exposing + a partial or transient copy under its final name. Ledger cleanup deletes + the source as soon as a digest-identical copy is visible in the read-only + directory, so the copy is published atomically once the source is no + longer needed and, if a mutate callback is given, only after the callback + has been applied to the temporary copy. + """ + with tempfile.NamedTemporaryFile( + dir=os.path.dirname(dst), delete=False + ) as tmp_file: + tmp_path = tmp_file.name + + try: + shutil.copyfile(src, tmp_path) + if mutate is not None: + mutate(tmp_path) + os.replace(tmp_path, dst) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + def test_max_committed_ledger_chunk_files(network, args, read_only_ledger_dir): """ Verify that the periodic cleanup timer deletes committed ledger chunks @@ -4340,7 +4364,8 @@ def copy_new_committed_to_readonly(): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): dst = os.path.join(read_only_ledger_dir, f) if not os.path.exists(dst): - shutil.copy2(os.path.join(main_ledger_dir, f), dst) + src = os.path.join(main_ledger_dir, f) + copy_ledger_chunk_to_read_only_dir(src, dst) def wait_for_cleanup(max_count, timeout=15): end_time = time.time() + timeout @@ -4414,9 +4439,8 @@ def run_max_committed_ledger_chunk_files(const_args): main_ledger_dir = primary.get_main_ledger_dir() for f in os.listdir(main_ledger_dir): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): - shutil.copy2( - os.path.join(main_ledger_dir, f), - os.path.join(tmp_dir, f), + copy_ledger_chunk_to_read_only_dir( + os.path.join(main_ledger_dir, f), os.path.join(tmp_dir, f) ) test_max_committed_ledger_chunk_files(network, args, tmp_dir) @@ -4469,7 +4493,7 @@ def get_committed_chunks(d): for f in committed[:num_to_backup]: src = os.path.join(main_ledger_dir, f) dst = os.path.join(read_only_ledger_dir, f) - shutil.copy2(src, dst) + copy_ledger_chunk_to_read_only_dir(src, dst) backed_up.append(f) LOG.info(f"Backed up {f} to read-only dir") @@ -4556,18 +4580,21 @@ def get_committed_chunks(d): LOG.warning("Not enough committed chunks to test cleanup, skipping") return network - # Copy oldest chunk to read-only dir, but corrupt it + # Copy oldest chunk to read-only dir, but corrupt it. The corruption is + # applied before the copy is published under its final name: a transient + # digest-identical copy would legitimately allow cleanup to delete the + # source, making the assertion below fail spuriously. target_chunk = committed[0] src = os.path.join(main_ledger_dir, target_chunk) dst = os.path.join(read_only_ledger_dir, target_chunk) - shutil.copy2(src, dst) - # Corrupt the read-only copy by flipping a byte - with open(dst, "r+b") as f: - f.seek(0) - original_byte = f.read(1) - f.seek(0) - f.write(bytes([original_byte[0] ^ 0xFF])) + def flip_first_byte(path): + with open(path, "r+b") as f: + original_byte = f.read(1) + f.seek(0) + f.write(bytes([original_byte[0] ^ 0xFF])) + + copy_ledger_chunk_to_read_only_dir(src, dst, mutate=flip_first_byte) LOG.info(f"Corrupted read-only copy of {target_chunk}") @@ -4624,7 +4651,8 @@ def copy_new_committed_to_readonly(): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): dst = os.path.join(read_only_ledger_dir, f) if not os.path.exists(dst): - shutil.copy2(os.path.join(main_ledger_dir, f), dst) + src = os.path.join(main_ledger_dir, f) + copy_ledger_chunk_to_read_only_dir(src, dst) def get_latest_committed_snapshot_seqno(): best = None @@ -4743,10 +4771,9 @@ def run_post_snapshot_chunk_retention(const_args): main_ledger_dir = primary.get_main_ledger_dir() for f in os.listdir(main_ledger_dir): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): - shutil.copy2( - os.path.join(main_ledger_dir, f), - os.path.join(tmp_dir, f), - ) + src = os.path.join(main_ledger_dir, f) + dst = os.path.join(tmp_dir, f) + copy_ledger_chunk_to_read_only_dir(src, dst) test_post_snapshot_chunks_retained(network, args, tmp_dir) From f286a3fb81998e7b6f36410d7535000b82fd7407 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Sat, 12 Sep 2026 09:56:15 +0100 Subject: [PATCH 14/15] Fix changelog issues for version 7.0.15 and update version to 7.0.16 (#8357) Co-authored-by: Amaury Chamayou --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b28f9aca9d..5b74a62da91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [7.0.16]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.16 +### Changed + +- HTTP/1.x request targets, including query strings, are now bounded before accumulation by a new `max_request_target_size` setting (16 KB by default), independent of `max_header_size`. Oversized targets return HTTP 414 `RequestTargetTooLong`, increment the per-interface `request_target_too_long` error metric, and close the session. HTTP/2 limits are unchanged (#8333). + ### Fixed - Fixed a double free when setting a property on a JavaScript object fails, which application script could trigger while the request object was being built. Such failures are now reported as a failed request (#8356). @@ -38,10 +42,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Reaching the soft session cap on an unsecured RPC interface no longer terminates the node by attempting a TLS handshake without a certificate. (#8331) - Transactions from an earlier view are now rejected before entering the replication queue even after the node has stepped down. This prevents rolled-back writes from being replicated after a later election and blocking subsequent replication (#8293, #8295). - Nodes now retain a peer's reconnect address even when an incoming node-to-node channel was established before its Raft configuration was applied. Previously, losing that connection could prevent outbound consensus messages from reaching the peer and stall elections (#8336). +- Transactions with pending writes now correctly validate `foreach`, `size`, and `clear` observations of an existing empty KV table made at revision zero. Previously, these observations could be mistaken for no whole-map read dependency (#8320). +- The OpenAPI schema for `GET /node/consensus` and `GET /node/network` now correctly marks `details.primary_id` and `primary_id` as nullable, matching their `null` value while no primary is known (e.g. between elections). Previously the schema required a non-null string, causing spurious response validation failures (#8344). ### Changed -- HTTP/1.x request targets, including query strings, are now bounded before accumulation by a new `max_request_target_size` setting (16 KB by default), independent of `max_header_size`. Oversized targets return HTTP 414 `RequestTargetTooLong`, increment the per-interface `request_target_too_long` error metric, and close the session. HTTP/2 limits are unchanged (#8333). - Updated QuickJS to `2026-06-04`, with isolated build-time patches for out-of-memory backtrace handling and enforcement of lowered heap limits (#8340). - CBOR parsing now rejects composite (array or map) and tagged values used as map keys anywhere in the decoded document, including nested maps in optional COSE headers (#8297). @@ -49,11 +54,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Removed the exported `evercbor` CMake target and installed `libevercbor.a` library. Applications using CCF's public APIs that explicitly depend on this target or link this library directly must remove that dependency. No further build changes are necessary: the replacement CBOR implementation is linked transitively by CCF (#8297). -### Fixed - -- Transactions with pending writes now correctly validate `foreach`, `size`, and `clear` observations of an existing empty KV table made at revision zero. Previously, these observations could be mistaken for no whole-map read dependency (#8320). -- The OpenAPI schema for `GET /node/consensus` and `GET /node/network` now correctly marks `details.primary_id` and `primary_id` as nullable, matching their `null` value while no primary is known (e.g. between elections). Previously the schema required a non-null string, causing spurious response validation failures (#8344). - ## [7.0.14] [7.0.14]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.14 From 969f4754ee1bf6766cfce669e4efa31f4f6c8942 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Sat, 12 Sep 2026 22:27:06 +0100 Subject: [PATCH 15/15] Scrub native private-key copies in JS crypto bindings (#8354) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eddyashton <6000239+eddyashton@users.noreply.github.com> Co-authored-by: achamayou --- CHANGELOG.md | 1 + src/crypto/pem.cpp | 4 + src/js/core/context.cpp | 53 +--- src/js/extensions/ccf/crypto.cpp | 86 +++--- src/js/extensions/ccf/scoped_cleanse.h | 96 ++++++ src/js/test/js.cpp | 393 ++++++++++++++++++++++++- 6 files changed, 543 insertions(+), 90 deletions(-) create mode 100644 src/js/extensions/ccf/scoped_cleanse.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b74a62da91d..735409710e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- Temporary native PEM buffers, string copies, private JWK fields and JSON values owned by the `ccf.crypto.generateRsaKeyPair`, `ccf.crypto.generateEcdsaKeyPair`, `ccf.crypto.generateEddsaKeyPair`, `ccf.crypto.pemToJwk` (and its RSA/EdDSA variants), `ccf.crypto.jwkToPem` (and its RSA/EdDSA variants), and `ccf.crypto.sign` bindings are now scrubbed on scope exit. Previously these copies were scrubbed only on success or not at all. JavaScript-owned strings and internal library temporaries are not covered by this change (#8354). - Fixed a double free when setting a property on a JavaScript object fails, which application script could trigger while the request object was being built. Such failures are now reported as a failed request (#8356). - Historical states retrieved by JavaScript endpoints, through `ccf.historicalState` or `ccf.historical.getStateRange`, remain available through response conversion and are released when the request completes, rather than being retained for the lifetime of the node (#8355). - JavaScript `verifySnpAttestation()` and the deprecated C++ `ccf::pal::snp::Attestation` returned swapped `current_minor` and `current_build` values. Both now match the AMD SEV-SNP report layout, with `current_build` at offset `0x1E8` and `current_minor` at `0x1E9` (#8083). diff --git a/src/crypto/pem.cpp b/src/crypto/pem.cpp index 46947396381a..d3f0523c6a56 100644 --- a/src/crypto/pem.cpp +++ b/src/crypto/pem.cpp @@ -2,12 +2,16 @@ // Licensed under the Apache 2.0 License. #include "ccf/crypto/pem.h" +#include + namespace ccf::crypto { void Pem::check_pem_format() { if (!s.contains("-----BEGIN")) { + // Construction has not completed, so a caller's guard cannot run yet. + OPENSSL_cleanse(s.data(), s.size()); throw std::runtime_error("PEM constructed with non-PEM data"); } } diff --git a/src/js/core/context.cpp b/src/js/core/context.cpp index ee9c9f8698b5..d89709b085de 100644 --- a/src/js/core/context.cpp +++ b/src/js/core/context.cpp @@ -614,44 +614,23 @@ namespace ccf::js::core std::optional Context::to_str(const JSWrappedValue& x) const { - size_t len = 0; - const auto* val = JS_ToCStringLen(ctx, &len, x.val); - if (val == nullptr) - { - // JS_ToCStringLen returns nullptr when a JS exception is already set (eg - // OOM, or an exception during coercion). Preserve that exception for - // callers. - return std::nullopt; - } - // Construct with explicit length rather than relying on the returned - // buffer's NUL terminator, since the JS string may itself contain - // embedded NUL characters which would otherwise silently truncate it. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + return to_str(x.val); } std::optional Context::to_str(const JSValue& x) const { size_t len = 0; - const auto* val = JS_ToCStringLen(ctx, &len, x); - if (val == nullptr) - { - // JS_ToCStringLen returns nullptr when a JS exception is already set (eg - // OOM, or an exception during coercion). Preserve that exception for - // callers. - return std::nullopt; - } - // See comment in to_str(const JSWrappedValue&) above. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + return to_str(x, len); } std::optional Context::to_str( const JSValue& x, size_t& len) const { - const auto* val = JS_ToCStringLen(ctx, &len, x); + const auto free_cstring = [this](const char* str) { + JS_FreeCString(ctx, str); + }; + const std::unique_ptr val( + JS_ToCStringLen(ctx, &len, x), free_cstring); if (val == nullptr) { // JS_ToCStringLen returns nullptr when a JS exception is already set (eg @@ -659,26 +638,26 @@ namespace ccf::js::core // caller return std::nullopt; } - // See comment in to_str(const JSWrappedValue&) above. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + // Preserve embedded NUL bytes. The QuickJS buffer may alias a live JS + // string, so release it even if copying throws, but do not cleanse it. + return std::string(val.get(), len); } std::optional Context::to_str(const JSAtom& atom) const { size_t len = 0; - const auto* val = JS_AtomToCStringLen(ctx, &len, atom); + const auto free_cstring = [this](const char* str) { + JS_FreeCString(ctx, str); + }; + const std::unique_ptr val( + JS_AtomToCStringLen(ctx, &len, atom), free_cstring); if (val == nullptr) { // JS_AtomToCStringLen returns nullptr when a JS exception is already set // (eg OOM). Preserve that exception for callers. return std::nullopt; } - // See comment in to_str(const JSWrappedValue&) above. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + return std::string(val.get(), len); } void Context::add_extension(const js::extensions::ExtensionPtr& extension) diff --git a/src/js/extensions/ccf/crypto.cpp b/src/js/extensions/ccf/crypto.cpp index d3051720b960..ae6c3d5fbbd5 100644 --- a/src/js/extensions/ccf/crypto.cpp +++ b/src/js/extensions/ccf/crypto.cpp @@ -16,6 +16,7 @@ #include "ccf/js/core/context.h" #include "ds/internal_logger.h" #include "js/checks.h" +#include "js/extensions/ccf/scoped_cleanse.h" #include "tls/ca.h" #include @@ -107,12 +108,12 @@ namespace ccf::js::extensions try { ccf::crypto::Pem prv = k->private_key_pem(); + ccf::js::ScopedCleanse prv_guard(prv); ccf::crypto::Pem pub = k->public_key_pem(); auto r = jsctx.new_obj(); JS_CHECK_EXC(r); auto private_key = jsctx.new_string(prv.str()); - OPENSSL_cleanse(prv.data(), prv.size()); JS_CHECK_EXC(private_key); JS_CHECK_SET(r.set("privateKey", std::move(private_key))); auto public_key = jsctx.new_string(pub.str()); @@ -170,12 +171,12 @@ namespace ccf::js::extensions auto k = ccf::crypto::make_ec_key_pair(cid); ccf::crypto::Pem prv = k->private_key_pem(); + ccf::js::ScopedCleanse prv_guard(prv); ccf::crypto::Pem pub = k->public_key_pem(); auto r = jsctx.new_obj(); JS_CHECK_EXC(r); auto private_key = jsctx.new_string(prv.str()); - OPENSSL_cleanse(prv.data(), prv.size()); JS_CHECK_EXC(private_key); JS_CHECK_SET(r.set("privateKey", std::move(private_key))); auto public_key = jsctx.new_string(pub.str()); @@ -228,12 +229,12 @@ namespace ccf::js::extensions auto k = ccf::crypto::make_eddsa_key_pair(cid); ccf::crypto::Pem prv = k->private_key_pem(); + ccf::js::ScopedCleanse prv_guard(prv); ccf::crypto::Pem pub = k->public_key_pem(); auto r = jsctx.new_obj(); JS_CHECK_EXC(r); auto private_key = jsctx.new_string(prv.str()); - OPENSSL_cleanse(prv.data(), prv.size()); JS_CHECK_EXC(private_key); JS_CHECK_SET(r.set("privateKey", std::move(private_key))); auto public_key = jsctx.new_string(pub.str()); @@ -478,6 +479,7 @@ namespace ccf::js::extensions { return ccf::js::core::constants::Exception; } + ccf::js::ScopedCleanse pem_str_guard(*pem_str); std::optional kid = std::nullopt; if (argc == 2) @@ -491,38 +493,42 @@ namespace ccf::js::extensions } T jwk; + ccf::js::ScopedCleanse jwk_guard(jwk); try { + ccf::crypto::Pem pem(*pem_str); + ccf::js::ScopedCleanse pem_guard(pem); + if constexpr (std::is_same_v) { - auto pubk = ccf::crypto::make_ec_public_key(*pem_str); + auto pubk = ccf::crypto::make_ec_public_key(pem); jwk = pubk->public_key_jwk(kid); } else if constexpr (std::is_same_v) { - auto kp = ccf::crypto::make_ec_key_pair(*pem_str); + auto kp = ccf::crypto::make_ec_key_pair(pem); jwk = kp->private_key_jwk(kid); } else if constexpr (std::is_same_v) { - auto pubk = ccf::crypto::make_rsa_public_key(*pem_str); + auto pubk = ccf::crypto::make_rsa_public_key(pem); jwk = pubk->public_key_jwk(kid); } else if constexpr (std::is_same_v) { - auto kp = ccf::crypto::make_rsa_key_pair(*pem_str); + auto kp = ccf::crypto::make_rsa_key_pair(pem); jwk = kp->private_key_jwk(kid); } else if constexpr (std:: is_same_v) { - auto pubk = ccf::crypto::make_eddsa_public_key(*pem_str); + auto pubk = ccf::crypto::make_eddsa_public_key(pem); jwk = pubk->public_key_jwk_eddsa(kid); } else if constexpr (std:: is_same_v) { - auto kp = ccf::crypto::make_eddsa_key_pair(*pem_str); + auto kp = ccf::crypto::make_eddsa_key_pair(pem); jwk = kp->private_key_jwk_eddsa(kid); } else @@ -538,7 +544,11 @@ namespace ccf::js::extensions try { - auto jwk_str = nlohmann::json(jwk).dump(); + nlohmann::json jwk_json; + ccf::js::ScopedCleanse jwk_json_guard(jwk_json); + ccf::crypto::to_json(jwk_json, jwk); + auto jwk_str = jwk_json.dump(); + ccf::js::ScopedCleanse jwk_str_guard(jwk_str); return JS_ParseJSON(ctx, jwk_str.c_str(), jwk_str.size(), ""); } catch (const std::exception& ex) @@ -566,12 +576,18 @@ namespace ccf::js::extensions { return ccf::js::core::constants::Exception; } + ccf::js::ScopedCleanse jwk_str_guard(*jwk_str); ccf::crypto::Pem pem; + ccf::js::ScopedCleanse pem_guard(pem); try { - T jwk = ccf::parse_json_safe(jwk_str.value()); + auto jwk_json = ccf::parse_json_safe(*jwk_str); + ccf::js::ScopedCleanse jwk_json_guard(jwk_json); + T jwk; + ccf::js::ScopedCleanse jwk_guard(jwk); + jwk_json.get_to(jwk); if constexpr (std::is_same_v) { @@ -619,25 +635,6 @@ namespace ccf::js::extensions return JS_NewString(ctx, pem.str().c_str()); } - // Cleanses (via OPENSSL_cleanse) the contents of the referenced - // container when the guard goes out of scope. Used to scrub owned copies - // of key material on all exit paths, including exceptions. - template - struct ScopeCleanse - { - T& secret; - explicit ScopeCleanse(T& s) : secret(s) {} - ScopeCleanse(const ScopeCleanse&) = delete; - ScopeCleanse& operator=(const ScopeCleanse&) = delete; - ~ScopeCleanse() - { - if (!secret.empty()) - { - OPENSSL_cleanse(secret.data(), secret.size()); - } - } - }; - // Reads the optional RSA-OAEP "label" parameter. An absent, null or // empty label means "no label", matching the behaviour of the previous // JS_GetArrayBuffer-based code, which silently ignored a null pointer. @@ -696,7 +693,7 @@ namespace ccf::js::extensions // wrapping key: that copy allocates and can fail, and the early return // below must not drop the plaintext without scrubbing it. auto& key = *key_opt; - ScopeCleanse key_cleanse(key); + ccf::js::ScopedCleanse key_cleanse(key); auto wrapping_key_opt = jsctx.copy_array_buffer(argv[1]); if (!wrapping_key_opt.has_value()) @@ -705,7 +702,7 @@ namespace ccf::js::extensions } // wrapping_key is a symmetric secret for AES-KWP. auto& wrapping_key = *wrapping_key_opt; - ScopeCleanse wrapping_key_cleanse(wrapping_key); + ccf::js::ScopedCleanse wrapping_key_cleanse(wrapping_key); auto parameters = argv[2]; auto wrap_algo_name_val = jsctx.get_property(parameters, "name"); @@ -833,7 +830,7 @@ namespace ccf::js::extensions // The guard is kept adjacent to the copy so that no fallible statement // can sit between creating the secret and protecting it. auto& unwrapping_key = *unwrapping_key_opt; - ScopeCleanse unwrapping_key_cleanse(unwrapping_key); + ccf::js::ScopedCleanse unwrapping_key_cleanse(unwrapping_key); auto parameters = argv[2]; auto wrap_algo_name_val = jsctx.get_property(parameters, "name"); @@ -864,13 +861,13 @@ namespace ccf::js::extensions auto pemPrivateUnwrappingKey = ccf::crypto::Pem(unwrapping_key.data(), unwrapping_key.size()); - ScopeCleanse pem_cleanse(pemPrivateUnwrappingKey); + ccf::js::ScopedCleanse pem_cleanse(pemPrivateUnwrappingKey); auto unwrapped_key = ccf::crypto::ckm_rsa_pkcs_oaep_unwrap( pemPrivateUnwrappingKey, key, label_opt); // The unwrapped key is plaintext secret material. This guard runs // after JS_NewArrayBufferCopy has taken its own copy. - ScopeCleanse unwrapped_cleanse(unwrapped_key); + ccf::js::ScopedCleanse unwrapped_cleanse(unwrapped_key); return JS_NewArrayBufferCopy( ctx, unwrapped_key.data(), unwrapped_key.size()); @@ -882,7 +879,7 @@ namespace ccf::js::extensions ccf::crypto::ckm_aes_key_unwrap_pad(unwrapping_key, key); // The unwrapped key is plaintext secret material. This guard runs // after JS_NewArrayBufferCopy has taken its own copy. - ScopeCleanse unwrapped_cleanse(unwrapped_key); + ccf::js::ScopedCleanse unwrapped_cleanse(unwrapped_key); return JS_NewArrayBufferCopy( ctx, unwrapped_key.data(), unwrapped_key.size()); @@ -911,13 +908,13 @@ namespace ccf::js::extensions auto privPemUnwrappingKey = ccf::crypto::Pem(unwrapping_key.data(), unwrapping_key.size()); - ScopeCleanse pem_cleanse(privPemUnwrappingKey); + ccf::js::ScopedCleanse pem_cleanse(privPemUnwrappingKey); auto unwrapped_key = ccf::crypto::ckm_rsa_aes_key_unwrap( privPemUnwrappingKey, key, label_opt); // The unwrapped key is plaintext secret material. This guard runs // after JS_NewArrayBufferCopy has taken its own copy. - ScopeCleanse unwrapped_cleanse(unwrapped_key); + ccf::js::ScopedCleanse unwrapped_cleanse(unwrapped_key); return JS_NewArrayBufferCopy( ctx, unwrapped_key.data(), unwrapped_key.size()); @@ -970,7 +967,8 @@ namespace ccf::js::extensions { return ccf::js::core::constants::Exception; } - auto key = *key_str; + ccf::js::ScopedCleanse key_str_guard(*key_str); + auto& key = *key_str; size_t data_size = 0; uint8_t* data = JS_GetArrayBuffer(ctx, &data_size, argv[2]); @@ -986,6 +984,7 @@ namespace ccf::js::extensions try { ccf::crypto::Pem key_pem(key); + ccf::js::ScopedCleanse key_pem_guard(key_pem); auto key_pair = ccf::crypto::make_eddsa_key_pair(key_pem); auto sig = key_pair->sign(contents); return JS_NewArrayBufferCopy(ctx, sig.data(), sig.size()); @@ -1034,7 +1033,9 @@ namespace ccf::js::extensions if (algo_name == "ECDSA") { - auto key_pair = ccf::crypto::make_ec_key_pair(key); + ccf::crypto::Pem key_pem(key); + ccf::js::ScopedCleanse key_pem_guard(key_pem); + auto key_pair = ccf::crypto::make_ec_key_pair(key_pem); auto sig_der = key_pair->sign(contents, mdtype); auto sig = ccf::crypto::ecdsa_sig_der_to_p1363( sig_der, key_pair->get_curve_id()); @@ -1043,7 +1044,9 @@ namespace ccf::js::extensions if (algo_name == "RSA-PSS") { - auto key_pair = ccf::crypto::make_rsa_key_pair(key); + ccf::crypto::Pem key_pem(key); + ccf::js::ScopedCleanse key_pem_guard(key_pem); + auto key_pair = ccf::crypto::make_rsa_key_pair(key_pem); int64_t salt_length{}; std::ignore = JS_ToInt64( @@ -1060,6 +1063,7 @@ namespace ccf::js::extensions if (algo_name == "HMAC") { std::vector vkey(key.begin(), key.end()); + ccf::js::ScopedCleanse> vkey_guard(vkey); const auto sig = ccf::crypto::hmac(mdtype, vkey, contents); return JS_NewArrayBufferCopy(ctx, sig.data(), sig.size()); } diff --git a/src/js/extensions/ccf/scoped_cleanse.h b/src/js/extensions/ccf/scoped_cleanse.h new file mode 100644 index 000000000000..3f94d6181b44 --- /dev/null +++ b/src/js/extensions/ccf/scoped_cleanse.h @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/crypto/jwk.h" + +#include + +namespace ccf::js +{ + namespace detail + { + template + requires requires(T& target) { + target.data(); + target.size(); + target.empty(); + } + void cleanse(T& target) + { + if (!target.empty()) + { + OPENSSL_cleanse(target.data(), target.size()); + } + } + + inline void cleanse(ccf::crypto::JsonWebKeyECPublic& jwk) {} + inline void cleanse(ccf::crypto::JsonWebKeyRSAPublic& jwk) {} + inline void cleanse(ccf::crypto::JsonWebKeyEdDSAPublic& jwk) {} + + inline void cleanse(ccf::crypto::JsonWebKeyECPrivate& jwk) + { + cleanse(jwk.d); + } + + inline void cleanse(ccf::crypto::JsonWebKeyRSAPrivate& jwk) + { + cleanse(jwk.d); + cleanse(jwk.p); + cleanse(jwk.q); + cleanse(jwk.dp); + cleanse(jwk.dq); + cleanse(jwk.qi); + } + + inline void cleanse(ccf::crypto::JsonWebKeyEdDSAPrivate& jwk) + { + cleanse(jwk.d); + } + + inline void cleanse(nlohmann::json& value) + { + if (auto* string = value.get_ptr()) + { + cleanse(*string); + } + else if (auto* array = value.get_ptr()) + { + for (auto& child : *array) + { + cleanse(child); + } + } + else if (auto* object = value.get_ptr()) + { + for (auto& [key, child] : *object) + { + cleanse(child); + } + } + } + } + + // Scrubs owned byte containers, private JWK fields and JSON string values + // on scope exit. The target must outlive the guard and must not discard + // secret storage (for example by shrinking or overwriting it) while guarded. + template + class ScopedCleanse + { + public: + explicit ScopedCleanse(T& target) : target(&target) {} + + ScopedCleanse(const ScopedCleanse&) = delete; + ScopedCleanse& operator=(const ScopedCleanse&) = delete; + ScopedCleanse(ScopedCleanse&&) = delete; + ScopedCleanse& operator=(ScopedCleanse&&) = delete; + + ~ScopedCleanse() + { + detail::cleanse(*target); + } + + private: + T* target; + }; +} diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index 33e73cbb6bab..b961d66f6326 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -11,6 +11,7 @@ #include "ccf/js/registry.h" #include "ccf/service/tables/modules.h" #include "enclave/http_rpc_context.h" +#include "js/extensions/ccf/scoped_cleanse.h" #include "js/global_class_ids.h" #include "js/interpreter_cache.h" #include "js/permissions_checks.h" @@ -1627,40 +1628,49 @@ export function run() { SUBCASE( "unwrapKey (AES-KWP): algorithm.name getter transfers the wrapped key") { - // Same > 512-byte allocation trick as the wrapKey test: a 1024-byte + // Same > 512-byte allocation trick as the wrapKey test: a 1032-byte // wrapped payload routes through the system malloc so ASAN can see // the transfer(0) as a real free and catch the C++ read. + // Use valid ciphertext to avoid OpenSSL's heap corruption on failed + // AES-KWP unwraps (see #8358); this test targets re-entry, not rejection. ccf::js::core::Context ctx(TxAccess::APP_RW); ctx.add_extension(std::make_shared()); const auto script = R"( export function run() { - const wrapped = new Uint8Array(1024); - for (let i = 0; i < 1024; ++i) wrapped[i] = i & 0xff; + const key = new Uint8Array(1024); + for (let i = 0; i < key.length; ++i) key[i] = i & 0xff; const unwrappingKey = new Uint8Array(16); for (let i = 0; i < 16; ++i) unwrappingKey[i] = 0xa0 + i; + const wrapped = ccf.crypto.wrapKey( + key.buffer, unwrappingKey.buffer, { name: "AES-KWP" }); + if (wrapped.byteLength !== 1032) + throw new Error("Unexpected wrapped length"); const params = { get name() { - wrapped.buffer.transfer(0); + wrapped.transfer(0); return "AES-KWP"; } }; - try { - ccf.crypto.unwrapKey(wrapped.buffer, unwrappingKey.buffer, params); - return "no throw"; - } catch (e) { - return "threw"; + const unwrapped = new Uint8Array( + ccf.crypto.unwrapKey(wrapped, unwrappingKey.buffer, params)); + if (wrapped.byteLength !== 0) + throw new Error("Wrapped buffer was not detached"); + if (unwrapped.length !== key.length) + throw new Error("Unexpected unwrapped length"); + for (let i = 0; i < key.length; ++i) { + if (unwrapped[i] !== key[i]) + throw new Error("Unwrapped bytes differ from the original key"); } + return "ok"; } )"; auto func = ctx.get_exported_function(script, "run", "/reentry-unwrap.js"); const auto result = ctx.call_with_rt_options( func, {}, std::nullopt, ccf::js::core::RuntimeLimitsPolicy::NONE); REQUIRE_FALSE(result.is_exception()); - // Random ciphertext + random unwrapping key must fail; success would - // suggest we accidentally ran on freed memory. const auto s = ctx.to_str(result); REQUIRE(s.has_value()); - REQUIRE(*s == "threw"); + REQUIRE(*s == "ok"); } SUBCASE( @@ -2149,6 +2159,365 @@ export function run(request) { } } +TEST_CASE("ScopedCleanse scrubs secret bytes on scope exit") +{ + SUBCASE("std::string") + { + std::string secret(32, 'A'); + { + ccf::js::ScopedCleanse guard(secret); + REQUIRE(secret == std::string(32, 'A')); + } + // The guard destructor has zeroed the string's bytes in place. The + // std::string object itself is still alive here so its buffer can be + // safely inspected. + for (char c : secret) + { + CHECK(c == '\0'); + } + } + + SUBCASE("std::vector") + { + std::vector secret(32, 0xAB); + { + ccf::js::ScopedCleanse> guard(secret); + REQUIRE(secret == std::vector(32, 0xAB)); + } + for (auto b : secret) + { + CHECK(b == 0); + } + } + + SUBCASE("ccf::crypto::Pem") + { + const std::string pem_text = + "-----BEGIN FAKE-----\nabcdefghij\n-----END FAKE-----\n"; + ccf::crypto::Pem pem(pem_text); + REQUIRE(pem.str() == pem_text); + { + ccf::js::ScopedCleanse guard(pem); + } + for (size_t i = 0; i < pem.size(); ++i) + { + CHECK(pem.data()[i] == 0); + } + } + + SUBCASE("Scrubs on exception unwind") + { + std::string secret(16, 'S'); + try + { + ccf::js::ScopedCleanse guard(secret); + throw std::runtime_error("boom"); + } + catch (const std::runtime_error&) + {} + for (char c : secret) + { + CHECK(c == '\0'); + } + } + + SUBCASE("Empty target is a no-op") + { + std::string empty; + ccf::js::ScopedCleanse guard(empty); + CHECK(empty.empty()); + } + + SUBCASE("Private JWK fields") + { + ccf::crypto::JsonWebKeyECPrivate ec; + ccf::crypto::JsonWebKeyEdDSAPrivate eddsa; + ccf::crypto::JsonWebKeyRSAPrivate rsa; + ec.d = eddsa.d = "short secret"; + ec.x = eddsa.x = "public"; + rsa.n = "public"; + for (auto* field : {&rsa.d, &rsa.p, &rsa.q, &rsa.dp, &rsa.dq, &rsa.qi}) + { + *field = std::string(64, 'S'); + } + { + ccf::js::ScopedCleanse ec_guard(ec); + ccf::js::ScopedCleanse eddsa_guard(eddsa); + ccf::js::ScopedCleanse rsa_guard(rsa); + } + CHECK(ec.d == std::string(12, '\0')); + CHECK(eddsa.d == std::string(12, '\0')); + CHECK(ec.x == "public"); + CHECK(eddsa.x == "public"); + CHECK(rsa.n == "public"); + for (const auto* field : + {&rsa.d, &rsa.p, &rsa.q, &rsa.dp, &rsa.dq, &rsa.qi}) + { + CHECK(*field == std::string(64, '\0')); + } + } + + SUBCASE("Partially deserialised JWK on exception unwind") + { + nlohmann::json json = { + {"kty", "RSA"}, + {"n", "public"}, + {"e", "AQAB"}, + {"d", "secret"}, + {"p", "secret"}}; + ccf::crypto::JsonWebKeyRSAPrivate jwk; + const auto parse = [&]() { + ccf::js::ScopedCleanse json_guard(json); + ccf::js::ScopedCleanse jwk_guard(jwk); + json.get_to(jwk); + }; + CHECK_THROWS_AS(parse(), std::exception); + CHECK(jwk.d == std::string(6, '\0')); + CHECK(jwk.p == std::string(6, '\0')); + CHECK(jwk.q.empty()); + CHECK(json["d"] == std::string(6, '\0')); + CHECK(json["p"] == std::string(6, '\0')); + } + + SUBCASE("JSON strings in objects and arrays on early return") + { + nlohmann::json json = { + {"d", "secret"}, + {"nested", nlohmann::json::array({"another", 42, nullptr, true})}}; + const auto leave_scope = [&]() { + ccf::js::ScopedCleanse guard(json); + return; + }; + leave_scope(); + CHECK(json["d"] == std::string(6, '\0')); + CHECK(json["nested"][0] == std::string(7, '\0')); + CHECK(json["nested"][1] == 42); + CHECK(json["nested"][2].is_null()); + CHECK(json["nested"][3] == true); + } + + SUBCASE("JSON roots on exception unwind") + { + const auto check_unwind = + [](nlohmann::json value, const nlohmann::json& expected) { + const auto fail = [&]() { + ccf::js::ScopedCleanse guard(value); + throw std::runtime_error("boom"); + }; + CHECK_THROWS_AS(fail(), std::runtime_error); + CHECK(value.type() == expected.type()); + CHECK(value == expected); + }; + check_unwind("secret", std::string(6, '\0')); + check_unwind( + nlohmann::json::array({"secret", {{"d", "nested"}}}), + nlohmann::json::array( + {std::string(6, '\0'), {{"d", std::string(6, '\0')}}})); + for (const auto& value : + {nlohmann::json(), + nlohmann::json(true), + nlohmann::json(42), + nlohmann::json(42u), + nlohmann::json(3.5), + nlohmann::json(""), + nlohmann::json::array(), + nlohmann::json::object(), + nlohmann::json::binary({1, 2, 3})}) + { + check_unwind(value, value); + } + } +} + +namespace +{ + // Helper: run a JS snippet through a ccf.crypto-equipped context and assert + // it returned without throwing. The snippet must define and return from a + // handler() function. + void run_crypto_handler(const std::string& body) + { + ccf::js::CommonContext ctx(TxAccess::APP_RW); + JS_UpdateStackTop(ctx.runtime()); + const auto module = + fmt::format("export function handler() {{\n{}\n}}", body); + auto handler = + ctx.get_exported_function(module, "handler", "/test/crypto.js"); + const auto result = ctx.call_with_rt_options( + handler, {}, std::nullopt, ccf::js::core::RuntimeLimitsPolicy::NONE); + if (result.is_exception()) + { + const auto [reason, trace] = ctx.error_message(); + FAIL("JS threw: ", reason); + } + } +} + +TEST_CASE("ccf.crypto private-key bindings still succeed after scrubbing") +{ + // These regression tests exercise the binding paths whose C++-side private + // key copies are now guarded by ScopedCleanse. Direct assertion that + // freed memory was scrubbed would be undefined behaviour; the unit tests + // above cover the guard's scrubbing semantics. + + SUBCASE("generateRsaKeyPair") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateRsaKeyPair(2048); + if (typeof kp.privateKey !== "string" || kp.privateKey.length === 0) + throw new Error("bad privateKey"); + if (typeof kp.publicKey !== "string" || kp.publicKey.length === 0) + throw new Error("bad publicKey"); + )JS"); + } + + SUBCASE("generateEcdsaKeyPair") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEcdsaKeyPair("secp256r1"); + if (!kp.privateKey.includes("PRIVATE KEY")) + throw new Error("bad privateKey"); + if (!kp.publicKey.includes("PUBLIC KEY")) + throw new Error("bad publicKey"); + )JS"); + } + + SUBCASE("generateEddsaKeyPair") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEddsaKeyPair("curve25519"); + if (!kp.privateKey.includes("PRIVATE KEY")) + throw new Error("bad privateKey"); + if (!kp.publicKey.includes("PUBLIC KEY")) + throw new Error("bad publicKey"); + )JS"); + } + + SUBCASE("Private and public JWK conversions preserve caller-owned values") + { + run_crypto_handler(R"JS( + const cases = [ + [ccf.crypto.generateEcdsaKeyPair("secp256r1"), + ccf.crypto.pemToJwk, ccf.crypto.jwkToPem, + ccf.crypto.pubPemToJwk, ccf.crypto.pubJwkToPem], + [ccf.crypto.generateRsaKeyPair(2048), + ccf.crypto.rsaPemToJwk, ccf.crypto.rsaJwkToPem, + ccf.crypto.pubRsaPemToJwk, ccf.crypto.pubRsaJwkToPem], + [ccf.crypto.generateEddsaKeyPair("curve25519"), + ccf.crypto.eddsaPemToJwk, ccf.crypto.eddsaJwkToPem, + ccf.crypto.pubEddsaPemToJwk, ccf.crypto.pubEddsaJwkToPem], + ]; + for (const [kp, toJwk, toPem, pubToJwk, pubToPem] of cases) { + for (const [pem, encode, decode] of [ + [kp.privateKey, toJwk, toPem], + [kp.publicKey, pubToJwk, pubToPem], + ]) { + const jwk = encode(pem, "test-key"); + const original = JSON.stringify(jwk); + const roundTrip = decode(jwk); + if (JSON.stringify(encode(roundTrip, "test-key")) !== original) + throw new Error("key round trip changed JWK"); + if (JSON.stringify(jwk) !== original || + JSON.stringify(encode(pem, "test-key")) !== original) + throw new Error("caller-owned key was scrubbed"); + } + } + )JS"); + } + + SUBCASE("sign with ECDSA") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEcdsaKeyPair("secp256r1"); + const data = ccf.strToBuf("hello"); + const sig = ccf.crypto.sign( + {name: "ECDSA", hash: "SHA-256"}, kp.privateKey, data); + if (!(sig instanceof ArrayBuffer) || sig.byteLength === 0) + throw new Error("bad signature"); + if (!ccf.crypto.verifySignature( + {name: "ECDSA", hash: "SHA-256"}, kp.publicKey, sig, data)) + throw new Error("signature did not verify"); + )JS"); + } + + SUBCASE("sign with EdDSA") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEddsaKeyPair("curve25519"); + const data = ccf.strToBuf("hello"); + const sig = ccf.crypto.sign( + {name: "EdDSA"}, kp.privateKey, data); + if (!(sig instanceof ArrayBuffer) || sig.byteLength === 0) + throw new Error("bad signature"); + if (!ccf.crypto.verifySignature( + {name: "EdDSA"}, kp.publicKey, sig, data)) + throw new Error("signature did not verify"); + )JS"); + } + + SUBCASE("sign with RSA-PSS") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateRsaKeyPair(2048); + const data = ccf.strToBuf("hello"); + const algorithm = {name: "RSA-PSS", hash: "SHA-256", saltLength: 32}; + const sig = ccf.crypto.sign(algorithm, kp.privateKey, data); + if (!ccf.crypto.verifySignature(algorithm, kp.publicKey, sig, data)) + throw new Error("signature did not verify"); + )JS"); + } + + SUBCASE("HMAC accepts a non-PEM key") + { + run_crypto_handler(R"JS( + const key = String.fromCharCode(11).repeat(20); + const sig = new Uint8Array(ccf.crypto.sign( + {name: "HMAC", hash: "SHA-256"}, key, ccf.strToBuf("Hi There"))); + const hex = Array.from(sig, b => b.toString(16).padStart(2, "0")).join(""); + if (hex !== "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7") + throw new Error("bad HMAC"); + if (key !== String.fromCharCode(11).repeat(20)) + throw new Error("caller-owned HMAC key was scrubbed"); + )JS"); + } + + SUBCASE("Malformed PEMs and JWKs still throw") + { + run_crypto_handler(R"JS( + function expectError(fn) { + let threw = false; + try { fn(); } catch (e) { threw = true; } + if (!threw) throw new Error("malformed key was accepted"); + } + for (const pem of ["not a PEM", "-----BEGIN PRIVATE KEY-----\ninvalid"]) { + for (const toJwk of [ + ccf.crypto.pemToJwk, ccf.crypto.rsaPemToJwk, ccf.crypto.eddsaPemToJwk, + ]) { + expectError(() => toJwk(pem)); + } + for (const name of ["ECDSA", "EdDSA", "RSA-PSS"]) { + expectError(() => ccf.crypto.sign( + {name, hash: "SHA-256", saltLength: 32}, pem, ccf.strToBuf("hello"))); + } + } + const jwks = [ + [ccf.crypto.pemToJwk(ccf.crypto.generateEcdsaKeyPair("secp256r1").privateKey), + ccf.crypto.jwkToPem, "d"], + [ccf.crypto.rsaPemToJwk(ccf.crypto.generateRsaKeyPair(2048).privateKey), + ccf.crypto.rsaJwkToPem, "qi"], + [ccf.crypto.eddsaPemToJwk(ccf.crypto.generateEddsaKeyPair("curve25519").privateKey), + ccf.crypto.eddsaJwkToPem, "d"], + ]; + for (const [jwk, toPem, field] of jwks) { + jwk[field] = {}; + const original = JSON.stringify(jwk); + expectError(() => toPem(jwk)); + if (JSON.stringify(jwk) !== original) + throw new Error("caller-owned invalid JWK was scrubbed"); + } + )JS"); + } +} + int main(int argc, char** argv) { ccf::js::register_class_ids();