diff --git a/CHANGELOG.md b/CHANGELOG.md index 736045598c81..d4d57d48c388 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. ### Added +- Added `ledger_signatures.mode` configuration option to enable `Dual` or `COSE`-only signing (#7772). - Added support for inline transaction receipt construction at commit time. Endpoint authors can use `build_receipt_for_committed_tx()` to construct a full `TxReceiptImpl` from the `CommittedTxInfo` passed to their `ConsensusCommittedEndpointFunction` callback. See the logging sample app (`/log/blocking/private/receipt`) for example usage (#7785). ### Changed diff --git a/doc/host_config_schema/cchost_config.json b/doc/host_config_schema/cchost_config.json index e56c890f080d..0da8ed6afc30 100644 --- a/doc/host_config_schema/cchost_config.json +++ b/doc/host_config_schema/cchost_config.json @@ -623,6 +623,12 @@ "type": "string", "default": "1000ms", "description": "Maximum duration after which a signature transaction is automatically generated" + }, + "mode": { + "type": "string", + "enum": ["Dual", "COSE"], + "default": "Dual", + "description": "Ledger signature mode. Dual emits both regular and COSE signatures. COSE emits only COSE signatures." } }, "description": "This section includes configuration for the ledger signatures emitted by this node (note: should be the same for all other nodes in the service). Transaction commit latency in a CCF network is primarily a function of signature frequency. A network emitting signatures more frequently will be able to commit transactions faster, but will spend a larger proportion of its execution resources creating and verifying signatures. Setting signature frequency is a trade-off between transaction latency and throughput", diff --git a/doc/operations/configuration.rst b/doc/operations/configuration.rst index b43970ed329d..3b761c1df419 100644 --- a/doc/operations/configuration.rst +++ b/doc/operations/configuration.rst @@ -40,3 +40,32 @@ Since these operations may require disk IO and produce large responses, these fe - Size strings are expressed as the value suffixed with the size in bytes (``B``, ``KB``, ``MB``, ``GB``, ``TB``, as factors of 1024), e.g. ``"20MB"``, ``"100KB"`` or ``"2048"`` (bytes). - Time strings are expressed as the value suffixed with the duration (``us``, ``ms``, ``s``, ``min``, ``h``), e.g. ``"1000ms"``, ``"10s"`` or ``"30min"``. + + +COSE-Only Ledger Signatures +---------------------------- + +By default, CCF nodes emit **dual** ledger signatures: a traditional node signature (stored in ``ccf.internal.signatures``) and a COSE Sign1 signature (stored in ``ccf.internal.cose_signatures``). The ``ledger_signatures.mode`` configuration option allows switching to **COSE-only** mode, where only COSE signatures are emitted. + +To enable COSE-only mode, set the ``mode`` field in your node configuration: + +.. code-block:: json + + { + "ledger_signatures": { + "mode": "COSE" + } + } + +When ``ledger_signatures.mode`` is set to ``"COSE"``: + +- The node signs ledger entries using only COSE Sign1 with the service key. Traditional node signatures (``ccf.internal.signatures``) are not emitted. +- The signature mode is preserved across service recovery. When recovering a COSE-only service, pass the same ``"COSE"`` mode in the recovery node configuration. +- All nodes in a service must use the same signature mode. + +The default value is ``"Dual"``, which retains backward-compatible behaviour by emitting both signature types. + +.. warning:: + + COSE-only mode is only supported from CCF 7.x onwards and is **not compatible with 6.x ledgers**. Operators upgrading from 6.x must first complete the migration to 7.x in ``"Dual"`` mode, and only then switch to ``"COSE"`` mode on a subsequent reconfiguration or recovery. Attempting to recover a 6.x ledger with ``"COSE"`` mode will fail. + diff --git a/doc/schemas/app_openapi.json b/doc/schemas/app_openapi.json index 3a2a934703a2..059777ba3f82 100644 --- a/doc/schemas/app_openapi.json +++ b/doc/schemas/app_openapi.json @@ -31,6 +31,10 @@ }, "type": "object" }, + "Cose": { + "format": "binary", + "type": "string" + }, "GetCommit__Out": { "properties": { "transaction_id": { @@ -1692,6 +1696,41 @@ } } }, + "/app/receipt/cose": { + "get": { + "description": "A COSE Sign1 envelope containing a signed statement from the service over a transaction entry in the ledger, with a Merkle proof in the unprotected header.", + "operationId": "GetAppReceiptCose", + "parameters": [ + { + "in": "query", + "name": "transaction_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransactionId" + } + } + ], + "responses": { + "200": { + "content": { + "application/cose": { + "schema": { + "$ref": "#/components/schemas/Cose" + } + } + }, + "description": "Default response description" + }, + "default": { + "$ref": "#/components/responses/default" + } + }, + "summary": "COSE receipt for a transaction", + "x-ccf-forwarding": { + "$ref": "#/components/x-ccf-forwarding/sometimes" + } + } + }, "/app/tx": { "get": { "description": "Possible statuses returned are Unknown, Pending, Committed or Invalid.", diff --git a/doc/schemas/node_openapi.json b/doc/schemas/node_openapi.json index c5359fd8fa77..73e04b370356 100644 --- a/doc/schemas/node_openapi.json +++ b/doc/schemas/node_openapi.json @@ -198,6 +198,10 @@ ], "type": "string" }, + "Cose": { + "format": "binary", + "type": "string" + }, "Endorsement": { "properties": { "authority": { @@ -1683,6 +1687,41 @@ } } }, + "/node/receipt/cose": { + "get": { + "description": "A COSE Sign1 envelope containing a signed statement from the service over a transaction entry in the ledger, with a Merkle proof in the unprotected header.", + "operationId": "GetNodeReceiptCose", + "parameters": [ + { + "in": "query", + "name": "transaction_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransactionId" + } + } + ], + "responses": { + "200": { + "content": { + "application/cose": { + "schema": { + "$ref": "#/components/schemas/Cose" + } + } + }, + "description": "Default response description" + }, + "default": { + "$ref": "#/components/responses/default" + } + }, + "summary": "COSE receipt for a transaction", + "x-ccf-forwarding": { + "$ref": "#/components/x-ccf-forwarding/sometimes" + } + } + }, "/node/self_signed_certificate": { "get": { "operationId": "GetNodeSelfSignedCertificate", diff --git a/include/ccf/ds/openapi.h b/include/ccf/ds/openapi.h index 887375ef16e7..d9dbe9bcccef 100644 --- a/include/ccf/ds/openapi.h +++ b/include/ccf/ds/openapi.h @@ -20,6 +20,25 @@ * fill every _required_ field, and the resulting object can be further * modified by hand as required. */ +namespace ccf::ds::openapi +{ + /** Tag type representing a binary COSE body (application/cose). */ + struct Cose + {}; + + inline void fill_json_schema( + nlohmann::json& schema, [[maybe_unused]] const Cose* cose) + { + schema["type"] = "string"; + schema["format"] = "binary"; + } + + inline std::string schema_name([[maybe_unused]] const Cose* cose) + { + return "Cose"; + } +} + namespace ccf::ds::openapi { namespace access @@ -393,6 +412,10 @@ namespace ccf::ds::openapi { return http::headervalues::contenttype::TEXT; } + else if constexpr (std::is_same_v) + { + return http::headervalues::contenttype::COSE; + } else { return http::headervalues::contenttype::JSON; diff --git a/include/ccf/node/startup_config.h b/include/ccf/node/startup_config.h index 527e96461e61..4ab847fb116f 100644 --- a/include/ccf/node/startup_config.h +++ b/include/ccf/node/startup_config.h @@ -53,10 +53,17 @@ namespace ccf }; Ledger ledger = {}; + enum class LedgerSignMode : uint8_t + { + Dual = 0, + COSE = 1 + }; + struct LedgerSignatures { size_t tx_count = 5000; ccf::ds::TimeString delay = {"1000ms"}; + LedgerSignMode mode = LedgerSignMode::Dual; bool operator==(const LedgerSignatures&) const = default; }; diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 2947dba687b5..8a542fa549b4 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -195,6 +195,8 @@ def verify_receipt( """ Verify a COSE Sign1 receipt as defined in https://datatracker.ietf.org/doc/draft-ietf-cose-merkle-tree-proofs/, using the CCF tree algorithm defined in https://datatracker.ietf.org/doc/draft-birkholz-cose-receipts-ccf-profile/ + + If claim_digest is None, the claims digest in the leaf is not verified. """ key_pem = key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode( "ascii" @@ -227,7 +229,7 @@ def verify_receipt( proof = cbor2.loads(inclusion_proof) assert CCF_PROOF_LEAF_LABEL in proof, "Leaf must be present" leaf = proof[CCF_PROOF_LEAF_LABEL] - if claim_digest != leaf[2]: + if claim_digest is not None and claim_digest != leaf[2]: raise ValueError(f"Claim digest mismatch: {leaf[2]!r} != {claim_digest!r}") accumulator = hashlib.sha256( leaf[0] + hashlib.sha256(leaf[1].encode()).digest() + leaf[2] diff --git a/python/src/ccf/ledger.py b/python/src/ccf/ledger.py index f903eb82b3b3..682d19aa07bf 100644 --- a/python/src/ccf/ledger.py +++ b/python/src/ccf/ledger.py @@ -724,10 +724,14 @@ def add_transaction(self, transaction): else: self.node_certificates[node_id] = endorsed_node_cert - # This is a merkle root/signature tx if the table exists - if SIGNATURE_TX_TABLE_NAME in tables: + # This is a merkle root/signature tx if either signature table exists + is_signature_tx = ( + SIGNATURE_TX_TABLE_NAME in tables or COSE_SIGNATURE_TX_TABLE_NAME in tables + ) + if is_signature_tx: self.signature_count += 1 + if SIGNATURE_TX_TABLE_NAME in tables: if self.verification_level >= VerificationLevel.MERKLE: signature_table = tables[SIGNATURE_TX_TABLE_NAME] diff --git a/samples/config/join_config.json b/samples/config/join_config.json index a002c469dd9b..59ecede19df0 100644 --- a/samples/config/join_config.json +++ b/samples/config/join_config.json @@ -42,7 +42,8 @@ }, "ledger_signatures": { "tx_count": 5000, - "delay": "1s" + "delay": "1s", + "mode": "Dual" }, "jwt": { "key_refresh_interval": "30min" diff --git a/samples/config/recover_config.json b/samples/config/recover_config.json index 9c3ad210e330..1440a6a1f80d 100644 --- a/samples/config/recover_config.json +++ b/samples/config/recover_config.json @@ -42,7 +42,8 @@ }, "ledger_signatures": { "tx_count": 5000, - "delay": "1s" + "delay": "1s", + "mode": "Dual" }, "jwt": { "key_refresh_interval": "30min" diff --git a/samples/config/start_config.json b/samples/config/start_config.json index fd7b9ab15152..0a485a021f49 100644 --- a/samples/config/start_config.json +++ b/samples/config/start_config.json @@ -71,7 +71,8 @@ }, "ledger_signatures": { "tx_count": 5000, - "delay": "1s" + "delay": "1s", + "mode": "Dual" }, "jwt": { "key_refresh_interval": "30min" diff --git a/src/common/configuration.h b/src/common/configuration.h index a46469bd33d1..231dafd85766 100644 --- a/src/common/configuration.h +++ b/src/common/configuration.h @@ -67,9 +67,15 @@ namespace ccf DECLARE_JSON_OPTIONAL_FIELDS( CCFConfig::Ledger, directory, read_only_directories, chunk_size); + DECLARE_JSON_ENUM( + CCFConfig::LedgerSignMode, + {{CCFConfig::LedgerSignMode::Dual, "Dual"}, + {CCFConfig::LedgerSignMode::COSE, "COSE"}}); + DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::LedgerSignatures); DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::LedgerSignatures); - DECLARE_JSON_OPTIONAL_FIELDS(CCFConfig::LedgerSignatures, tx_count, delay); + DECLARE_JSON_OPTIONAL_FIELDS( + CCFConfig::LedgerSignatures, tx_count, delay, mode); DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::JWT); DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::JWT); diff --git a/src/endpoints/common_endpoint_registry.cpp b/src/endpoints/common_endpoint_registry.cpp index 9b9f39434e6d..d92e119a2426 100644 --- a/src/endpoints/common_endpoint_registry.cpp +++ b/src/endpoints/common_endpoint_registry.cpp @@ -10,6 +10,7 @@ #include "ccf/http_query.h" #include "ccf/json_handler.h" #include "ccf/node_context.h" +#include "ccf/receipt.h" #include "ccf/service/tables/code_id.h" #include "ccf/service/tables/host_data.h" #include "ccf/service/tables/snp_measurements.h" @@ -285,6 +286,45 @@ namespace ccf "A signed statement from the service over a transaction entry in the " "ledger") .install(); + + auto get_cose_receipt = + []( + auto& ctx, + ccf::historical::StatePtr + historical_state) { // NOLINT(performance-unnecessary-value-param) + assert(historical_state->receipt); + auto cose_receipt = + ccf::describe_cose_receipt_v1(*historical_state->receipt); + if (!cose_receipt.has_value()) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_NOT_FOUND, + ccf::errors::ResourceNotFound, + "No COSE receipt available for this transaction."); + return; + } + + ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK); + ctx.rpc_ctx->set_response_header( + ccf::http::headers::CONTENT_TYPE, + ccf::http::headervalues::contenttype::COSE); + ctx.rpc_ctx->set_response_body(*cose_receipt); + }; + + make_read_only_endpoint( + "/receipt/cose", + HTTP_GET, + ccf::historical::read_only_adapter_v4( + get_cose_receipt, context, is_tx_committed, txid_from_query_string), + no_auth_required) + .set_auto_schema() + .add_query_parameter(tx_id_param_key) + .set_openapi_summary("COSE receipt for a transaction") + .set_openapi_description( + "A COSE Sign1 envelope containing a signed statement from the " + "service over a transaction entry in the ledger, with a Merkle " + "proof in the unprotected header.") + .install(); } void CommonEndpointRegistry::api_endpoint( diff --git a/src/endpoints/endpoint_registry.cpp b/src/endpoints/endpoint_registry.cpp index 8fc3b19aaf6b..ed4fb5efdad1 100644 --- a/src/endpoints/endpoint_registry.cpp +++ b/src/endpoints/endpoint_registry.cpp @@ -256,13 +256,24 @@ namespace ccf::endpoints } auto proof = tree.get_proof(info.tx_id.seqno); + std::optional> sig; + std::optional cert; + NodeId node{}; + + if (cached_sig->sig) + { + sig = cached_sig->sig->sig; + cert = cached_sig->sig->cert; + node = cached_sig->sig->node; + } + return std::make_shared( - cached_sig->sig.sig, + sig, cached_sig->cose_signature, proof.get_root(), proof.get_path(), - cached_sig->sig.node, - cached_sig->sig.cert, + node, + cert, info.write_set_digest, info.commit_evidence, info.claims_digest); diff --git a/src/kv/deserialise.h b/src/kv/deserialise.h index dffff3e4c000..c7d99deee200 100644 --- a/src/kv/deserialise.h +++ b/src/kv/deserialise.h @@ -130,23 +130,24 @@ namespace ccf::kv } auto success = ApplyResult::PASS; - auto search = changes.find(ccf::Tables::SIGNATURES); - if (search != changes.end()) + const bool signature_in = + (changes.find(ccf::Tables::SIGNATURES) != changes.end()); + const bool cose_signature_in = + (changes.find(ccf::Tables::COSE_SIGNATURES) != changes.end()); + + if (signature_in || cose_signature_in) { + const bool merkle_tree_in = + changes.find(ccf::Tables::SERIALISED_MERKLE_TREE) != changes.end(); switch (changes.size()) { case 2: - if ( - changes.find(ccf::Tables::SERIALISED_MERKLE_TREE) != - changes.end()) + if (merkle_tree_in && (cose_signature_in != signature_in)) { break; } case 3: - if ( - changes.find(ccf::Tables::SERIALISED_MERKLE_TREE) != - changes.end() && - changes.find(ccf::Tables::COSE_SIGNATURES) != changes.end()) + if (merkle_tree_in && cose_signature_in && signature_in) { break; } @@ -169,7 +170,7 @@ namespace ccf::kv success = ApplyResult::PASS_SIGNATURE; } - search = changes.find(ccf::Tables::ENCRYPTED_PAST_LEDGER_SECRET); + auto search = changes.find(ccf::Tables::ENCRYPTED_PAST_LEDGER_SECRET); if (search != changes.end()) { success = ApplyResult::PASS_ENCRYPTED_PAST_LEDGER_SECRET; diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index b02171021ff2..f24d59e567be 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -12,6 +12,7 @@ #include "ccf/kv/hooks.h" #include "ccf/kv/version.h" #include "ccf/node/cose_signatures_config.h" +#include "ccf/node/startup_config.h" #include "ccf/service/consensus_type.h" #include "ccf/service/reconfiguration_type.h" #include "ccf/tx_id.h" @@ -358,7 +359,9 @@ namespace ccf::kv virtual void start_signature_emit_timer() = 0; virtual void set_service_signing_identity( std::shared_ptr keypair, - const COSESignaturesConfig& cose_signatures) = 0; + const COSESignaturesConfig& cose_signatures, + CCFConfig::LedgerSignMode ledger_signature_mode = + CCFConfig::LedgerSignMode::Dual) = 0; virtual const ccf::COSESignaturesConfig& get_cose_signatures_config() = 0; }; diff --git a/src/node/gov/handlers/acks.h b/src/node/gov/handlers/acks.h index a4ed1ce81242..4614d8990357 100644 --- a/src/node/gov/handlers/acks.h +++ b/src/node/gov/handlers/acks.h @@ -5,6 +5,7 @@ #include "ccf/base_endpoint_registry.h" #include "node/gov/api_version.h" #include "node/gov/handlers/helpers.h" +#include "node/history.h" #include "node/share_manager.h" #include "service/internal_tables_access.h" @@ -124,11 +125,11 @@ namespace ccf::gov::endpoints ack = ack_opt.value(); } - // Get signature, containing merkle root state digest - auto sigs_handle = - ctx.tx.template ro(Tables::SIGNATURES); - auto sig = sigs_handle->get(); - if (!sig.has_value()) + // Get merkle root state digest from serialised merkle tree + auto tree_handle = ctx.tx.template ro( + Tables::SERIALISED_MERKLE_TREE); + auto tree = tree_handle->get(); + if (!tree.has_value()) { detail::set_gov_error( ctx.rpc_ctx, @@ -137,9 +138,10 @@ namespace ccf::gov::endpoints "Service has no signatures to ack yet - try again soon."); return; } + ccf::MerkleTreeHistory history(tree.value()); // Write ack back to the KV - ack.state_digest = sig->root.hex_str(); + ack.state_digest = history.get_root().hex_str(); acks_handle->put(member_id, ack); auto body = nlohmann::json::object(); diff --git a/src/node/historical_queries.h b/src/node/historical_queries.h index 7bbc414019b2..55fd7b7c81f0 100644 --- a/src/node/historical_queries.h +++ b/src/node/historical_queries.h @@ -7,6 +7,7 @@ #include "consensus/ledger_enclave_types.h" #include "ds/ccf_assert.h" #include "kv/store.h" +#include "node/cose_common.h" #include "node/encryptor.h" #include "node/history.h" #include "node/ledger_secrets.h" @@ -496,11 +497,11 @@ 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); - if (!sig.has_value()) + const auto cose_sig = get_cose_signature(sig_details->store); + if (!sig.has_value() && !cose_sig.has_value()) { return false; } - const auto cose_sig = get_cose_signature(sig_details->store); const auto serialised_tree = get_tree(sig_details->store); if (!serialised_tree.has_value()) { @@ -526,17 +527,46 @@ namespace ccf::historical if (details != nullptr && details->store != nullptr) { auto proof = tree.get_proof(seqno); - details->transaction_id = {sig->view, seqno}; - details->receipt = std::make_shared( - sig->sig, - cose_sig, - proof.get_root(), - proof.get_path(), - sig->node, - sig->cert, - details->entry_digest, - details->get_commit_evidence(), - details->claims_digest); + + if (sig.has_value()) + { + details->transaction_id = {sig->view, seqno}; + details->receipt = std::make_shared( + sig->sig, + cose_sig, + proof.get_root(), + proof.get_path(), + sig->node, + sig->cert, + details->entry_digest, + details->get_commit_evidence(), + details->claims_digest); + } + else + { + auto cose_receipt = + ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + auto parsed_txid = + ccf::TxID::from_str(cose_receipt.phdr.ccf.txid); + if (!parsed_txid.has_value()) + { + throw std::logic_error(fmt::format( + "Cannot parse CCF TxID: {}", cose_receipt.phdr.ccf.txid)); + } + + details->transaction_id = {parsed_txid->view, seqno}; + details->receipt = std::make_shared( + std::nullopt, + cose_sig, + proof.get_root(), + proof.get_path(), + ccf::NodeId{}, + std::nullopt, + details->entry_digest, + details->get_commit_evidence(), + details->claims_digest); + } + HISTORICAL_LOG( "Assigned a receipt for {} after given signature at {}", seqno, @@ -819,6 +849,32 @@ namespace ccf::historical details->receipt = std::make_shared( sig->sig, cose_sig, sig->root.h, nullptr, sig->node, sig->cert); } + else if (cose_sig.has_value()) + { + auto as_receipt = + ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + const auto& txid = as_receipt.phdr.ccf.txid; + auto parsed_txid = ccf::TxID::from_str(txid); + + if (!parsed_txid.has_value()) + { + throw std::logic_error( + fmt::format("Cannot parse CCF TxID: {}", txid)); + } + details->transaction_id = parsed_txid.value(); + details->receipt = std::make_shared( + std::nullopt, + cose_sig, + std::nullopt, + nullptr, + ccf::NodeId{}, + std::nullopt); + } + else + { + throw std::logic_error( + fmt::format("Seqno {} is a signature of an unknown type", seqno)); + } } auto request_it = requests.begin(); diff --git a/src/node/historical_queries_adapter.cpp b/src/node/historical_queries_adapter.cpp index 3058e80ad51c..84b18550da01 100644 --- a/src/node/historical_queries_adapter.cpp +++ b/src/node/historical_queries_adapter.cpp @@ -68,7 +68,12 @@ namespace ccf // Legacy JSON format, retained for compatibility nlohmann::json out = nlohmann::json::object(); - out["signature"] = ccf::crypto::b64_from_raw(receipt.signature); + if (!receipt.signature.has_value()) + { + throw std::logic_error( + "Non-COSE receipt requires a non-COSE signature TX"); + } + out["signature"] = ccf::crypto::b64_from_raw(receipt.signature.value()); auto proof = nlohmann::json::array(); if (receipt.path != nullptr) @@ -99,7 +104,12 @@ namespace ccf if (receipt.path == nullptr) { // Signature transaction - out["leaf"] = receipt.root.to_string(); + if (!receipt.root.has_value()) + { + throw std::logic_error( + "Non-COSE receipt on a signature requires root to be set"); + } + out["leaf"] = receipt.root.value().to_string(); } else if (!receipt.commit_evidence.has_value()) { @@ -181,17 +191,23 @@ namespace ccf else { // Signature transaction - auto sig_receipt = std::make_shared(); - sig_receipt->signed_root = ccf::crypto::Sha256Hash::from_span( - std::span( - in.root.bytes, sizeof(in.root.bytes))); + auto sig_receipt = std::make_shared(); + if (in.root.has_value()) + { + sig_receipt->signed_root = ccf::crypto::Sha256Hash::from_span( + std::span( + in.root.value().bytes, sizeof(in.root.value().bytes))); + } receipt = sig_receipt; } auto& out = *receipt; - out.signature = in.signature; + if (in.signature.has_value()) + { + out.signature = in.signature.value(); + } out.node_id = in.node_id; @@ -264,12 +280,10 @@ namespace ccf { return std::nullopt; } - auto proof = describe_merkle_proof_v1(receipt); if (!proof.has_value()) { - // Signature TX: return COSE signature as-is, with empty UHDR - return signature; + return std::nullopt; } auto inclusion_proof = diff --git a/src/node/history.h b/src/node/history.h index 64d28ba6dedd..77f88c51238a 100644 --- a/src/node/history.h +++ b/src/node/history.h @@ -6,6 +6,7 @@ #include "ccf/pal/locking.h" #include "ccf/service/tables/nodes.h" #include "ccf/service/tables/service.h" +#include "common/configuration.h" #include "cose/cose_rs_ffi.h" #include "crypto/cose.h" #include "crypto/openssl/ec_key_pair.h" @@ -181,7 +182,9 @@ namespace ccf void set_service_signing_identity( std::shared_ptr service_kp_, - const ccf::COSESignaturesConfig& /*cose_signatures*/) override + const ccf::COSESignaturesConfig& /*cose_signatures*/, + CCFConfig::LedgerSignMode /*ledger_signature_mode*/ = + CCFConfig::LedgerSignMode::Dual) override { std::ignore = service_kp_; } @@ -310,6 +313,7 @@ namespace ccf ccf::crypto::ECKeyPair_OpenSSL& service_kp; ccf::crypto::Pem& endorsed_cert; const ccf::COSESignaturesConfig& cose_signatures_config; + const CCFConfig::LedgerSignMode ledger_signature_mode; std::unordered_map& cose_key_cache; public: @@ -322,6 +326,7 @@ namespace ccf ccf::crypto::ECKeyPair_OpenSSL& service_kp_, ccf::crypto::Pem& endorsed_cert_, const ccf::COSESignaturesConfig& cose_signatures_config_, + CCFConfig::LedgerSignMode ledger_signature_mode_, std::unordered_map& cose_key_cache_) : txid(txid_), store(store_), @@ -331,34 +336,39 @@ namespace ccf service_kp(service_kp_), endorsed_cert(endorsed_cert_), cose_signatures_config(cose_signatures_config_), + ledger_signature_mode(ledger_signature_mode_), cose_key_cache(cose_key_cache_) {} ccf::kv::PendingTxInfo call() override { auto sig = store.create_reserved_tx(txid); - auto* signatures = - sig.template wo(ccf::Tables::SIGNATURES); - auto* cose_signatures = - sig.template wo(ccf::Tables::COSE_SIGNATURES); - auto* serialised_tree = sig.template wo( - ccf::Tables::SERIALISED_MERKLE_TREE); ccf::crypto::Sha256Hash root = history.get_replicated_state_root(); - std::vector primary_sig; - std::vector root_hash{ root.h.data(), root.h.data() + root.h.size()}; - primary_sig = node_kp.sign_hash(root_hash.data(), root_hash.size()); - PrimarySignature sig_value( - id, - txid.seqno, - txid.view, - root, - {}, // Nonce is currently empty - primary_sig, - endorsed_cert); + if (ledger_signature_mode == CCFConfig::LedgerSignMode::Dual) + { + auto* signatures = + sig.template wo(ccf::Tables::SIGNATURES); + auto primary_sig = + node_kp.sign_hash(root_hash.data(), root_hash.size()); + + PrimarySignature sig_value( + id, + txid.seqno, + txid.view, + root, + {}, // Nonce is currently empty + primary_sig, + endorsed_cert); + + signatures->put(sig_value); + } + + auto* cose_signatures = + sig.template wo(ccf::Tables::COSE_SIGNATURES); auto kid = ccf::crypto::kid_from_key(service_kp.public_key_der()); const auto tx_id = txid.to_str(); @@ -410,9 +420,12 @@ namespace ccf } std::vector cose_sign(cose_buf.to_vector()); - signatures->put(sig_value); cose_signatures->put(cose_sign); + + auto* serialised_tree = sig.template wo( + ccf::Tables::SERIALISED_MERKLE_TREE); serialised_tree->put(history.serialise_tree(txid.seqno - 1)); + return sig.commit_reserved(); } }; @@ -568,6 +581,8 @@ namespace ccf { const std::shared_ptr service_kp; const ccf::COSESignaturesConfig cose_signatures_config; + const CCFConfig::LedgerSignMode ledger_signature_mode = + CCFConfig::LedgerSignMode::Dual; }; std::optional signing_identity = std::nullopt; @@ -596,7 +611,9 @@ namespace ccf void set_service_signing_identity( std::shared_ptr service_kp_, - const ccf::COSESignaturesConfig& cose_signatures_config_) override + const ccf::COSESignaturesConfig& cose_signatures_config_, + CCFConfig::LedgerSignMode ledger_signature_mode_ = + CCFConfig::LedgerSignMode::Dual) override { if (signing_identity.has_value()) { @@ -604,13 +621,15 @@ namespace ccf "Called set_service_signing_identity() multiple times"); } - signing_identity.emplace( - ServiceSigningIdentity{service_kp_, cose_signatures_config_}); + signing_identity.emplace(ServiceSigningIdentity{ + service_kp_, cose_signatures_config_, ledger_signature_mode_}); LOG_INFO_FMT( - "Setting service signing identity to iss: {} sub: {}", + "Setting service signing identity to iss: {} sub: {}. Ledger " + "signature mode: {}", cose_signatures_config_.issuer, - cose_signatures_config_.subject); + cose_signatures_config_.subject, + nlohmann::json(ledger_signature_mode_).dump()); } const ccf::COSESignaturesConfig& get_cose_signatures_config() override @@ -752,20 +771,27 @@ namespace ccf { auto tx = store.create_read_only_tx(); + auto root = get_replicated_state_root(); + log_hash(root, VERIFY); + auto* signatures = tx.template ro(ccf::Tables::SIGNATURES); auto sig = signatures->get(); - if (!sig.has_value()) - { - LOG_FAIL_FMT("No signature found in signatures map"); - return false; - } - - auto root = get_replicated_state_root(); - log_hash(root, VERIFY); - if (!verify_node_signature(tx, sig->node, sig->sig, root)) + if (sig.has_value()) { - return false; + // Only verify the node signature if it was actually written in this + // version. In COSE-only mode, the signatures table is not written, + // so an old value may be present from a previous dual-signed + // transaction. Verifying that stale signature against the current + // root would fail. + const auto sig_version = signatures->get_version_of_previous_write(); + if (sig_version.has_value() && sig_version.value() == version) + { + if (!verify_node_signature(tx, sig->node, sig->sig, root)) + { + return false; + } + } } auto* cose_signatures = @@ -774,7 +800,15 @@ namespace ccf if (!cose_sig.has_value()) { - return true; + // It's possible we are reading some old non-COSE ledger entry. + // In that case, it's enough to only verify regular sig. + if (sig.has_value()) + { + return true; + } + + LOG_FAIL_FMT("No signatures found in COSE signatures map"); + return false; } // Since COSE signatures have not always been emitted, it is possible in a @@ -906,6 +940,7 @@ namespace ccf *signing_identity->service_kp, endorsed_cert.value(), signing_identity->cose_signatures_config, + signing_identity->ledger_signature_mode, cose_key_cache), true); } diff --git a/src/node/node_state.h b/src/node/node_state.h index 8678026e0142..53b89d0fdff0 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -941,7 +941,9 @@ namespace ccf network.ledger_secrets->init(); history->set_service_signing_identity( - network.identity->get_key_pair(), config.cose_signatures); + network.identity->get_key_pair(), + config.cose_signatures, + config.ledger_signatures.mode); setup_consensus(false, endorsed_node_cert); @@ -1152,7 +1154,8 @@ namespace ccf history->set_service_signing_identity( network.identity->get_key_pair(), resp.network_info->cose_signatures_config.value_or( - ccf::COSESignaturesConfig{})); + ccf::COSESignaturesConfig{}), + config.ledger_signatures.mode); ccf::crypto::Pem n2n_channels_cert; if (!resp.network_info->endorsed_certificate.has_value()) @@ -1194,14 +1197,34 @@ namespace ccf } auto tx = network.tables->create_read_only_tx(); - auto* signatures = tx.ro(network.signatures); - auto sig = signatures->get(); - if (!sig.has_value()) + auto last_sig = tx.ro(network.signatures)->get(); + if (last_sig.has_value()) { - throw std::logic_error( - fmt::format("No signatures found after applying snapshot")); + view = last_sig->view; + } + else + { + auto* cose_signatures = tx.ro(network.cose_signatures); + auto cose_sig = cose_signatures->get(); + if (!cose_sig.has_value()) + { + throw std::logic_error( + "No signature found after applying snapshot"); + } + + auto receipt = + ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + const auto& txid = receipt.phdr.ccf.txid; + auto parsed_txid = ccf::TxID::from_str(txid); + + if (!parsed_txid.has_value()) + { + throw std::logic_error( + fmt::format("Cannot parse CCF TxID: {}", txid)); + } + + view = parsed_txid->view; } - view = sig->view; if (!resp.network_info->public_only) { @@ -1453,17 +1476,33 @@ namespace ccf // If the ledger entry is a signature, it is safe to compact the store network.tables->compact(last_recovered_idx); auto tx = network.tables->create_read_only_tx(); - auto last_sig = tx.ro(network.signatures)->get(); - if (!last_sig.has_value()) + ccf::kv::Term sig_view = 0; + auto last_sig = tx.ro(network.signatures)->get(); + if (last_sig.has_value()) + { + sig_view = last_sig->view; + } + else { - throw std::logic_error("Signature missing"); + auto lcs = tx.ro(network.cose_signatures)->get(); + if (!lcs.has_value()) + { + throw std::logic_error("Signature missing"); + } + auto receipt = cose::decode_ccf_receipt(lcs.value(), false); + auto tx_id_opt = ccf::TxID::from_str(receipt.phdr.ccf.txid); + if (!tx_id_opt.has_value()) + { + throw std::logic_error(fmt::format( + "Failed to parse TxID from COSE signature: {}", + receipt.phdr.ccf.txid)); + } + sig_view = tx_id_opt->view; } LOG_DEBUG_FMT( - "Read signature at {} for view {}", - last_recovered_idx, - last_sig->view); + "Read signature at {} for view {}", last_recovered_idx, sig_view); // Initial transactions, before the first signature, must have // happened in the first signature's view (eg - if the first // signature is at seqno 20 in view 4, then transactions 1->19 must @@ -1473,12 +1512,8 @@ namespace ccf // valid signature. const auto view_start_idx = view_history.empty() ? 1 : last_recovered_signed_idx + 1; - CCF_ASSERT_FMT( - last_sig->view >= 0, - "last_sig->view is invalid, {}", - last_sig->view); - for (auto i = view_history.size(); - i < static_cast(last_sig->view); + CCF_ASSERT_FMT(sig_view >= 0, "sig_view is invalid, {}", sig_view); + for (auto i = view_history.size(); i < static_cast(sig_view); ++i) { view_history.push_back(view_start_idx); @@ -1549,10 +1584,6 @@ namespace ccf index = s.seqno; view = s.view; } - else - { - throw std::logic_error("No signature found after recovery"); - } ccf::COSESignaturesConfig cs_cfg{}; auto lcs = tx.ro(network.cose_signatures)->get(); @@ -1560,14 +1591,34 @@ namespace ccf { CoseSignature cs = lcs.value(); LOG_INFO_FMT("COSE signature found after recovery"); + + auto as_receipt = + cose::decode_ccf_receipt(cs, /* recompute_root */ false); + try { - auto receipt = - cose::decode_ccf_receipt(cs, /* recompute_root */ false); - auto issuer = receipt.phdr.cwt.iss; - auto subject = receipt.phdr.cwt.sub; + auto tx_id_opt = ccf::TxID::from_str(as_receipt.phdr.ccf.txid); + if (!tx_id_opt.has_value()) + { + throw std::logic_error(fmt::format( + "Failed to parse TxID from COSE signature: {}", + as_receipt.phdr.ccf.txid)); + } + + // Use the COSE signature's TxID only if it is newer than the + // traditional signature's. In flip-flop scenarios (dual -> COSE -> + // dual) the COSE signature may be older than the traditional one. + if (tx_id_opt->seqno > index) + { + index = tx_id_opt->seqno; + view = tx_id_opt->view; + } + + auto issuer = as_receipt.phdr.cwt.iss; + auto subject = as_receipt.phdr.cwt.sub; LOG_INFO_FMT( "COSE signature issuer: {}, subject: {}", issuer, subject); + cs_cfg = ccf::COSESignaturesConfig{issuer, subject}; } catch (const cose::COSEDecodeError& e) @@ -1581,8 +1632,15 @@ namespace ccf LOG_INFO_FMT("No COSE signature found after recovery"); } + if (!ls.has_value() && !lcs.has_value()) + { + throw std::logic_error("No signature found after recovery"); + } + history->set_service_signing_identity( - network.identity->get_key_pair(), cs_cfg); + network.identity->get_key_pair(), + cs_cfg, + config.ledger_signatures.mode); auto* h = dynamic_cast(history.get()); if (h != nullptr) diff --git a/src/node/signature_cache_interface.h b/src/node/signature_cache_interface.h index e1099da71765..5e3cf30f0067 100644 --- a/src/node/signature_cache_interface.h +++ b/src/node/signature_cache_interface.h @@ -13,8 +13,8 @@ namespace ccf { struct CachedSignature { - PrimarySignature sig; - std::vector cose_signature; + std::optional sig; + std::optional> cose_signature; std::vector serialised_tree; ccf::SeqNo sig_seqno; }; @@ -30,7 +30,8 @@ namespace ccf } // Returns the covering signature for a given seqno, or nullopt if - // unavailable. When a value is returned, all fields are populated. + // unavailable. When a value is returned, Merkle tree and at least one of + // the signatures is populated, or both if present. [[nodiscard]] virtual std::optional get_signature_for( ccf::SeqNo seqno) const = 0; diff --git a/src/node/signature_cache_subsystem.h b/src/node/signature_cache_subsystem.h index e33fb85b71ca..9d0fb1cf7f8c 100644 --- a/src/node/signature_cache_subsystem.h +++ b/src/node/signature_cache_subsystem.h @@ -22,7 +22,7 @@ namespace ccf [[nodiscard]] bool is_complete() const { - return sig.has_value() && cose_signature.has_value() && + return (sig.has_value() || cose_signature.has_value()) && serialised_tree.has_value(); } }; @@ -82,15 +82,15 @@ namespace ccf const auto& [version, entry] = *it; if ( - !entry.sig.has_value() || !entry.cose_signature.has_value() || + !(entry.sig.has_value() || entry.cose_signature.has_value()) || !entry.serialised_tree.has_value()) { return std::nullopt; } return CachedSignature{ - entry.sig.value(), - entry.cose_signature.value(), + entry.sig, + entry.cose_signature, entry.serialised_tree.value(), version}; } diff --git a/src/node/test/history.cpp b/src/node/test/history.cpp index 39c955488238..44ee849a90de 100644 --- a/src/node/test/history.cpp +++ b/src/node/test/history.cpp @@ -74,6 +74,14 @@ class DummyConsensus : public ccf::kv::test::StubConsensus TEST_CASE("Check signature verification") { + auto sig_mode = ccf::CCFConfig::LedgerSignMode::Dual; + + SUBCASE("normal") {} + SUBCASE("cose_only") + { + sig_mode = ccf::CCFConfig::LedgerSignMode::COSE; + } + auto encryptor = std::make_shared(); auto node_kp = ccf::crypto::make_ec_key_pair(); @@ -90,7 +98,7 @@ TEST_CASE("Check signature verification") primary_store, ccf::kv::test::PrimaryNodeId, *node_kp); primary_history->set_endorsed_certificate(self_signed); primary_history->set_service_signing_identity( - service_kp, ccf::COSESignaturesConfig{}); + service_kp, ccf::COSESignaturesConfig{}, sig_mode); primary_store.set_history(primary_history); primary_store.initialise_term(store_term); @@ -101,13 +109,14 @@ TEST_CASE("Check signature verification") backup_store, ccf::kv::test::FirstBackupNodeId, *node_kp); backup_history->set_endorsed_certificate(self_signed); backup_history->set_service_signing_identity( - service_kp, ccf::COSESignaturesConfig{}); + service_kp, ccf::COSESignaturesConfig{}, sig_mode); backup_store.set_history(backup_history); backup_store.initialise_term(store_term); ccf::Nodes nodes(ccf::Tables::NODES); ccf::Service service(ccf::Tables::SERVICE); - ccf::Signatures signatures(ccf::Tables::SIGNATURES); + ccf::Signatures signatures_table(ccf::Tables::SIGNATURES); + ccf::CoseSignatures cose_signatures(ccf::Tables::COSE_SIGNATURES); std::shared_ptr consensus = std::make_shared(&backup_store); @@ -139,19 +148,50 @@ TEST_CASE("Check signature verification") REQUIRE(backup_store.current_version() == 2); } + INFO("COSE signature table should be populated"); + { + auto tx = primary_store.create_read_only_tx(); + auto cose_sigs = tx.ro(cose_signatures); + REQUIRE(cose_sigs->get().has_value()); + } + + if (sig_mode == ccf::CCFConfig::LedgerSignMode::COSE) + { + INFO("In COSE-only mode, node signature table should not be populated"); + auto tx = primary_store.create_read_only_tx(); + auto sigs = tx.ro(signatures_table); + REQUIRE_FALSE(sigs->get().has_value()); + } + else + { + INFO("In normal mode, node signature table should be populated"); + auto tx = primary_store.create_read_only_tx(); + auto sigs = tx.ro(signatures_table); + REQUIRE(sigs->get().has_value()); + } + INFO("Issue a bogus signature, rejected by verification on the backup"); { auto txs = primary_store.create_tx(); - auto sigs = txs.rw(signatures); - ccf::PrimarySignature bogus(ccf::kv::test::PrimaryNodeId, 0); - bogus.sig = std::vector(256, 1); - sigs->put(bogus); + auto cose_sigs = txs.rw(cose_signatures); + ccf::CoseSignature bogus{}; + cose_sigs->put(bogus); REQUIRE(txs.commit() == ccf::kv::CommitResult::FAIL_NO_REPLICATE); } } TEST_CASE("Check signing works across rollback") { + auto sig_mode = ccf::CCFConfig::LedgerSignMode::Dual; + + SUBCASE("normal") {} + SUBCASE("cose_only") + { + sig_mode = ccf::CCFConfig::LedgerSignMode::COSE; + } + + ccf::COSESignaturesConfig cose_config; + auto encryptor = std::make_shared(); auto node_kp = ccf::crypto::make_ec_key_pair(); @@ -168,7 +208,7 @@ TEST_CASE("Check signing works across rollback") primary_store, ccf::kv::test::PrimaryNodeId, *node_kp); primary_history->set_endorsed_certificate(self_signed); primary_history->set_service_signing_identity( - service_kp, ccf::COSESignaturesConfig{}); + service_kp, cose_config, sig_mode); primary_store.set_history(primary_history); primary_store.initialise_term(store_term); @@ -178,7 +218,7 @@ TEST_CASE("Check signing works across rollback") backup_store, ccf::kv::test::FirstBackupNodeId, *node_kp); backup_history->set_endorsed_certificate(self_signed); backup_history->set_service_signing_identity( - service_kp, ccf::COSESignaturesConfig{}); + service_kp, cose_config, sig_mode); backup_store.set_history(backup_history); backup_store.set_encryptor(encryptor); backup_store.initialise_term(store_term); diff --git a/src/node/tx_receipt_impl.h b/src/node/tx_receipt_impl.h index 079bd3ad0a6f..2dd465b925c3 100644 --- a/src/node/tx_receipt_impl.h +++ b/src/node/tx_receipt_impl.h @@ -12,9 +12,9 @@ namespace ccf // public interface by ccf::Receipt struct TxReceiptImpl { - std::vector signature; + std::optional> signature; std::optional> cose_signature = std::nullopt; - HistoryTree::Hash root; + std::optional root; std::shared_ptr path; ccf::NodeId node_id; std::optional node_cert = std::nullopt; @@ -26,9 +26,9 @@ namespace ccf std::optional cose_endorsements = std::nullopt; TxReceiptImpl( - const std::vector& signature_, + const std::optional>& signature_, const std::optional>& cose_signature, - const HistoryTree::Hash& root_, + const std::optional& root_, std::shared_ptr path_, NodeId node_id_, const std::optional& node_cert_, diff --git a/src/service/internal_tables_access.h b/src/service/internal_tables_access.h index 9457f0bffefd..15491f4dfad1 100644 --- a/src/service/internal_tables_access.h +++ b/src/service/internal_tables_access.h @@ -17,6 +17,7 @@ #include "ccf/tx.h" #include "consensus/aft/raft_types.h" #include "cose/cose_rs_ffi.h" +#include "node/history.h" #include "node/ledger_secrets.h" #include "node/uvm_endorsements.h" #include "service/tables/governance_history.h" @@ -499,19 +500,21 @@ namespace ccf auto* last_signed_root = tx.wo( ccf::Tables::PREVIOUS_SERVICE_LAST_SIGNED_ROOT); - auto* sigs = tx.ro(ccf::Tables::SIGNATURES); - if (!sigs->has()) + auto* tree_handle = + tx.ro(ccf::Tables::SERIALISED_MERKLE_TREE); + if (!tree_handle->has()) { throw std::logic_error( - "Previous service doesn't have any signed transactions"); + "Previous service doesn't have a serialised merkle tree"); } - auto sig_opt = sigs->get(); - if (!sig_opt.has_value()) + auto tree_opt = tree_handle->get(); + if (!tree_opt.has_value()) { throw std::logic_error( - "Previous service doesn't have signature value"); + "Previous service doesn't have serialised merkle tree value"); } - last_signed_root->put(sig_opt->root); + ccf::MerkleTreeHistory tree(tree_opt.value()); + last_signed_root->put(tree.get_root()); // Record number of recoveries for service. If the value does // not exist in the table (i.e. pre 2.x ledger), assume it is the diff --git a/tests/config.jinja b/tests/config.jinja index d6a55eed8afb..1e601ada18c0 100644 --- a/tests/config.jinja +++ b/tests/config.jinja @@ -94,7 +94,8 @@ "ledger_signatures": { "tx_count": {{ sig_tx_interval }}, - "delay": "{{ signature_interval_duration }}" + "delay": "{{ signature_interval_duration }}", + "mode": {{ ledger_signature_mode|tojson }} }, "jwt": { diff --git a/tests/e2e_logging.py b/tests/e2e_logging.py index ae6da203f426..2f69c1a1f813 100644 --- a/tests/e2e_logging.py +++ b/tests/e2e_logging.py @@ -45,6 +45,32 @@ from loguru import logger as LOG +def get_service_key(network): + service_cert_path = os.path.join(network.common_dir, "service_cert.pem") + service_cert = load_pem_x509_certificate( + open(service_cert_path, "rb").read(), default_backend() + ) + return service_cert.public_key() + + +def fetch_and_verify_cose_receipt( + client, view, seqno, service_key, claim_digest=None, timeout=3.0 +): + start_time = time.time() + while time.time() < (start_time + timeout): + rc = client.get(f"/node/receipt/cose?transaction_id={view}.{seqno}") + if rc.status_code == http.HTTPStatus.OK: + ccf.cose.verify_receipt(rc.body.data(), service_key, claim_digest) + return rc + elif rc.status_code == http.HTTPStatus.NOT_FOUND: + return rc + elif rc.status_code == http.HTTPStatus.ACCEPTED: + time.sleep(0.1) + else: + assert False, rc + assert False, f"Timed out fetching COSE receipt for {view}.{seqno}" + + def verify_receipt( receipt, service_cert, @@ -810,28 +836,42 @@ def test_historical_query(network, args): @reqs.description("Read historical receipts") @reqs.supports_methods("/app/log/private", "/app/log/private/historical_receipt") def test_historical_receipts(network, args): + cose_only = getattr(args, "ledger_signature_mode", "Dual") == "COSE" primary, backups = network.find_nodes() TXS_COUNT = 5 start_idx = network.txs.idx + 1 network.txs.issue(network, number_txs=TXS_COUNT) - for idx in range(start_idx, TXS_COUNT + start_idx): - for node in [primary, backups[0]]: - first_msg = network.txs.priv[idx][0] - first_receipt = network.txs.get_receipt( - node, idx, first_msg["seqno"], first_msg["view"] - ) - r = first_receipt.json()["receipt"] - verify_receipt(r, network.cert) - # receipt.verify() and ccf.receipt.check_endorsement() raise if they fail, but do not return anything - verified = True - try: - ccf.receipt.verify( - hashlib.sha256(b"").hexdigest(), r["signature"], network.cert - ) - except InvalidSignature: - verified = False - assert not verified + if cose_only: + service_key = get_service_key(network) + for idx in range(start_idx, TXS_COUNT + start_idx): + for node in [primary, backups[0]]: + first_msg = network.txs.priv[idx][0] + with node.client("user0") as c: + infra.commit.wait_for_commit( + c, first_msg["seqno"], first_msg["view"], timeout=3 + ) + fetch_and_verify_cose_receipt( + c, first_msg["view"], first_msg["seqno"], service_key + ) + else: + for idx in range(start_idx, TXS_COUNT + start_idx): + for node in [primary, backups[0]]: + first_msg = network.txs.priv[idx][0] + first_receipt = network.txs.get_receipt( + node, idx, first_msg["seqno"], first_msg["view"] + ) + r = first_receipt.json()["receipt"] + verify_receipt(r, network.cert) + + verified = True + try: + ccf.receipt.verify( + hashlib.sha256(b"").hexdigest(), r["signature"], network.cert + ) + except InvalidSignature: + verified = False + assert not verified return network @@ -839,53 +879,79 @@ def test_historical_receipts(network, args): @reqs.description("Read historical receipts with claims") @reqs.supports_methods("/app/log/public", "/app/log/public/historical_receipt") def test_historical_receipts_with_claims(network, args): + cose_only = getattr(args, "ledger_signature_mode", "Dual") == "COSE" primary, backups = network.find_nodes() TXS_COUNT = 5 start_idx = network.txs.idx + 1 network.txs.issue(network, number_txs=TXS_COUNT, record_claim=True) - for idx in range(start_idx, TXS_COUNT + start_idx): - for node in [primary, backups[0]]: - first_msg = network.txs.pub[idx][0] - first_receipt = network.txs.get_receipt( - node, idx, first_msg["seqno"], first_msg["view"], domain="public" - ) - r = first_receipt.json()["receipt"] - verify_receipt(r, network.cert, first_receipt.json()["msg"].encode()) - # receipt.verify() and ccf.receipt.check_endorsement() raise if they fail, but do not return anything - verified = True - try: - ccf.receipt.verify( - hashlib.sha256(b"").hexdigest(), r["signature"], network.cert - ) - except InvalidSignature: - verified = False - assert not verified + if cose_only: + service_key = get_service_key(network) + for idx in range(start_idx, TXS_COUNT + start_idx): + for node in [primary, backups[0]]: + first_msg = network.txs.pub[idx][0] + claim_digest = sha256(first_msg["msg"].encode()).digest() + with node.client("user0") as c: + infra.commit.wait_for_commit( + c, first_msg["seqno"], first_msg["view"], timeout=3 + ) + fetch_and_verify_cose_receipt( + c, + first_msg["view"], + first_msg["seqno"], + service_key, + claim_digest, + ) + else: + for idx in range(start_idx, TXS_COUNT + start_idx): + for node in [primary, backups[0]]: + first_msg = network.txs.pub[idx][0] + first_receipt = network.txs.get_receipt( + node, idx, first_msg["seqno"], first_msg["view"], domain="public" + ) + r = first_receipt.json()["receipt"] + verify_receipt(r, network.cert, first_receipt.json()["msg"].encode()) + + verified = True + try: + ccf.receipt.verify( + hashlib.sha256(b"").hexdigest(), r["signature"], network.cert + ) + except InvalidSignature: + verified = False + assert not verified return network @reqs.description("Read genesis receipt") def test_genesis_receipt(network, args): + cose_only = getattr(args, "ledger_signature_mode", "Dual") == "COSE" primary, _ = network.find_nodes() - genesis_receipt = primary.get_receipt(2, 1) - verify_receipt(genesis_receipt.json(), network.cert, generic=True) - claims_digest = genesis_receipt.json()["leaf_components"]["claims_digest"] - - with primary.client() as client: - constitution = client.get( - "/gov/service/constitution?api-version=2023-06-01-preview" - ).body.text() - - if args.package == "samples/apps/logging/logging": - # Only the logging app sets a claim on the genesis - assert claims_digest == sha256(constitution.encode()).hexdigest() + if cose_only: + service_key = get_service_key(network) + with primary.client("user0") as c: + rc = fetch_and_verify_cose_receipt(c, 2, 1, service_key) + assert rc.status_code == http.HTTPStatus.OK, rc else: - assert ( - claims_digest - == "0000000000000000000000000000000000000000000000000000000000000000" - ) + genesis_receipt = primary.get_receipt(2, 1) + verify_receipt(genesis_receipt.json(), network.cert, generic=True) + claims_digest = genesis_receipt.json()["leaf_components"]["claims_digest"] + + with primary.client() as client: + constitution = client.get( + "/gov/service/constitution?api-version=2023-06-01-preview" + ).body.text() + + if args.package == "samples/apps/logging/logging": + # Only the logging app sets a claim on the genesis + assert claims_digest == sha256(constitution.encode()).hexdigest() + else: + assert ( + claims_digest + == "0000000000000000000000000000000000000000000000000000000000000000" + ) return network @@ -1702,25 +1768,34 @@ def test_tx_statuses(network, args): @reqs.at_least_n_nodes(2) @app.scoped_txs() def test_receipts(network, args): + cose_only = getattr(args, "ledger_signature_mode", "Dual") == "COSE" primary, _ = network.find_primary_and_any_backup() msg = "Hello world" LOG.info("Write/Read on primary") - with primary.client("user0") as c: - for j in range(10): - idx = j + 10000 - r = network.txs.issue(network, 1, idx=idx, send_public=False, msg=msg) - start_time = time.time() - while time.time() < (start_time + 3.0): - rc = c.get(f"/app/receipt?transaction_id={r.view}.{r.seqno}") - if rc.status_code == http.HTTPStatus.OK: - receipt = rc.body.json() - verify_receipt(receipt, network.cert) - break - elif rc.status_code == http.HTTPStatus.ACCEPTED: - time.sleep(0.5) - else: - assert False, rc + if cose_only: + service_key = get_service_key(network) + with primary.client("user0") as c: + for j in range(10): + idx = j + 10000 + r = network.txs.issue(network, 1, idx=idx, send_public=False, msg=msg) + fetch_and_verify_cose_receipt(c, r.view, r.seqno, service_key) + else: + with primary.client("user0") as c: + for j in range(10): + idx = j + 10000 + r = network.txs.issue(network, 1, idx=idx, send_public=False, msg=msg) + start_time = time.time() + while time.time() < (start_time + 3.0): + rc = c.get(f"/app/receipt?transaction_id={r.view}.{r.seqno}") + if rc.status_code == http.HTTPStatus.OK: + receipt = rc.body.json() + verify_receipt(receipt, network.cert) + break + elif rc.status_code == http.HTTPStatus.ACCEPTED: + time.sleep(0.5) + else: + assert False, rc return network @@ -1736,6 +1811,8 @@ def test_random_receipts( node=None, log_capture=None, ): + cose_only = getattr(args, "ledger_signature_mode", "Dual") == "COSE" + if node is None: node, _ = network.find_primary_and_any_backup() @@ -1751,6 +1828,8 @@ def test_random_receipts( cert = c.read() certs[infra.crypto.compute_public_key_der_hash_hex_from_pem(cert)] = cert + service_key = get_service_key(network) + with node.client("user0") as c: r = c.get("/app/commit") max_view, max_seqno = [ @@ -1773,36 +1852,62 @@ def test_random_receipts( ): start_time = time.time() while time.time() < (start_time + 3.0): - rc = c.get( - f"/app/receipt?transaction_id={view}.{s}", log_capture=log_capture - ) - if rc.status_code == http.HTTPStatus.OK: - receipt = rc.body.json() - if "leaf" in receipt: - if not lts: - assert "proof" in receipt, receipt - assert len(receipt["proof"]) == 0, receipt - # Legacy signature receipt + if cose_only: + rc = c.get( + f"/node/receipt/cose?transaction_id={view}.{s}", + log_capture=log_capture, + ) + if rc.status_code == http.HTTPStatus.OK: + receipt_bytes = rc.body.data() + claim_digest = additional_seqnos.get(s) + ccf.cose.verify_receipt( + receipt_bytes, service_key, claim_digest + ) + break + elif rc.status_code == http.HTTPStatus.NOT_FOUND: + # Signature TX — no COSE receipt available, skip LOG.warning( - f"Skipping verification of signature receipt at {view}.{s}" + f"Skipping signature TX at {view}.{s} (no COSE receipt)" ) + break + elif rc.status_code == http.HTTPStatus.ACCEPTED: + time.sleep(0.1) else: - if lts and not receipt.get("cert"): - receipt["cert"] = certs[receipt["node_id"]] - verify_receipt( - receipt, - network.cert, - claims=additional_seqnos.get(s), - generic=True, - skip_cert_chain_checks=lts, - ) - break - elif rc.status_code == http.HTTPStatus.ACCEPTED: - time.sleep(0.1) + view += 1 + if view > max_view: + assert False, rc else: - view += 1 - if view > max_view: - assert False, rc + rc = c.get( + f"/app/receipt?transaction_id={view}.{s}", + log_capture=log_capture, + ) + if rc.status_code == http.HTTPStatus.OK: + receipt = rc.body.json() + if "leaf" in receipt: + if not lts: + assert "proof" in receipt, receipt + assert len(receipt["proof"]) == 0, receipt + # Legacy signature receipt + LOG.warning( + f"Skipping verification of signature receipt at {view}.{s}" + ) + else: + if lts and not receipt.get("cert"): + receipt["cert"] = certs[receipt["node_id"]] + verify_receipt( + receipt, + network.cert, + claims=additional_seqnos.get(s), + generic=True, + skip_cert_chain_checks=lts, + ) + break + elif rc.status_code == http.HTTPStatus.ACCEPTED: + time.sleep(0.1) + else: + view += 1 + if view > max_view: + assert False, rc return network @@ -2177,6 +2282,8 @@ def run_udp_tests(args): def run(args): + ledger_signature_mode = getattr(args, "ledger_signature_mode", "Dual") + # Listen on two additional RPC interfaces for each node def additional_interfaces(local_node_id): return { @@ -2199,7 +2306,7 @@ def additional_interfaces(local_node_id): pdb=args.pdb, txs=txs, ) as network: - network.start_and_open(args) + network.start_and_open(args, ledger_signature_mode=ledger_signature_mode) do_main_tests(network, args) @@ -2271,9 +2378,8 @@ def test_cose_config(network, args): configs.add(r.body.text()) assert len(configs) == 1, configs - assert ( - configs.pop() == '{"issuer":"service.example.com","subject":"ledger.signature"}' - ), configs + expected = '{"issuer":"service.example.com","subject":"ledger.signature"}' + assert configs.pop() == expected, configs return network @@ -2474,6 +2580,17 @@ def run_parsing_errors(args): initial_member_count=2, ) + cr.add( + "cpp_cose_only", + run, + package="samples/apps/logging/logging", + js_app_bundle=None, + nodes=infra.e2e_args.max_nodes(cr.args, f=0), + initial_user_count=4, + initial_member_count=2, + ledger_signature_mode="COSE", + ) + cr.add( "common", e2e_common_endpoints.run, diff --git a/tests/e2e_operations.py b/tests/e2e_operations.py index 172e8262674f..7917472a413a 100644 --- a/tests/e2e_operations.py +++ b/tests/e2e_operations.py @@ -33,6 +33,8 @@ import pathlib import infra.concurrency import ccf.read_ledger +import ccf.cose +import infra.commit import re import hashlib from contextlib import contextmanager @@ -1847,6 +1849,191 @@ def run_service_subject_name_check(args): assert cert.subject.rfc4514_string() == "CN=This test service", cert +def run_cose_only_mode_upgrade(args): + + def get_service_key(network): + service_cert_path = os.path.join(network.common_dir, "service_cert.pem") + service_cert = x509.load_pem_x509_certificate( + open(service_cert_path, "rb").read(), default_backend() + ) + return service_cert.public_key() + + def verify_receipt_available(node, view, seqno, service_key, expect_regular=True): + with node.client("user0") as c: + infra.commit.wait_for_commit(c, seqno, view, timeout=3) + + start_time = time.time() + while time.time() < start_time + 3: + rc = c.get(f"/node/receipt?transaction_id={view}.{seqno}") + if rc.status_code != http.HTTPStatus.ACCEPTED: + break + time.sleep(0.1) + + if expect_regular: + assert ( + rc.status_code == http.HTTPStatus.OK + ), f"Regular receipt should be available, got {rc.status_code}" + else: + assert ( + rc.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR + ), f"Regular receipt should fail on COSE-only network, got {rc.status_code}" + + start_time = time.time() + while time.time() < start_time + 3: + cose_rc = c.get(f"/node/receipt/cose?transaction_id={view}.{seqno}") + if cose_rc.status_code != http.HTTPStatus.ACCEPTED: + break + time.sleep(0.1) + + assert ( + cose_rc.status_code == http.HTTPStatus.OK + ), f"COSE receipt should be available, got {cose_rc.status_code}" + ccf.cose.verify_receipt(cose_rc.body.data(), service_key, None) + + LOG.info( + f"Receipt check OK for {view}.{seqno} " + f"(regular={'available' if expect_regular else 'unavailable'})" + ) + + def assert_ledger_cose_only_after(node, start_seqno): + current_ledger_dir, committed_ledger_dirs = node.get_ledger() + ledger = ccf.ledger.Ledger( + committed_ledger_dirs + [current_ledger_dir], committed_only=False + ) + + cose_sig_count = 0 + for chunk in ledger: + for tx in chunk: + pd = tx.get_public_domain() + tables = pd.get_tables() + seqno = pd.get_seqno() + + if seqno <= start_seqno: + continue + + assert ccf.ledger.SIGNATURE_TX_TABLE_NAME not in tables, ( + f"Found traditional (non-COSE) signature at seqno {seqno}, " + f"expected COSE-only after seqno {start_seqno}" + ) + + if ccf.ledger.COSE_SIGNATURE_TX_TABLE_NAME in tables: + cose_sig_count += 1 + + assert ( + cose_sig_count > 0 + ), "No COSE signatures found in ledger after old nodes were removed" + return cose_sig_count + + nargs = copy.deepcopy(args) + nargs.nodes = infra.e2e_args.max_nodes(nargs, f=0) + + with infra.network.network( + nargs.nodes, + nargs.binary_dir, + nargs.debug_nodes, + pdb=nargs.pdb, + txs=app.LoggingTxs("user0"), + ) as network: + network.start_and_open(nargs) + + primary, _ = network.find_nodes() + old_nodes = network.get_joined_nodes() + service_key = get_service_key(network) + + # Verify both regular and COSE receipts work on the dual-signing network + LOG.info("Verifying both receipt types work on dual-signing network") + pre_idx = network.txs.idx + 1 + network.txs.issue(network, number_txs=1) + pre_msg = network.txs.priv[pre_idx][0] + verify_receipt_available( + primary, pre_msg["view"], pre_msg["seqno"], service_key, expect_regular=True + ) + + # Step 1: Add new nodes with COSE mode + LOG.info("Adding new nodes with ledger_signature_mode=COSE") + new_nodes = [] + for _ in range(len(old_nodes)): + new_node = network.create_node() + network.join_node( + new_node, + nargs.package, + nargs, + ledger_signature_mode="COSE", + ) + network.trust_node(new_node, nargs) + new_nodes.append(new_node) + + # Issue some transactions to ensure the new nodes are caught up + network.txs.issue(network, number_txs=5) + + # Step 2: Remove old dual-signing nodes + LOG.info("Removing old dual-signing nodes") + new_primary = new_nodes[0] + for old_node in old_nodes: + network.retire_node(new_primary, old_node) + old_node.stop() + + # Record the seqno after all old nodes are removed + with new_primary.client() as c: + r = c.get("/node/state") + cose_only_start_seqno = r.body.json()["last_signed_seqno"] + LOG.info( + f"Old nodes removed. COSE-only starts after seqno {cose_only_start_seqno}" + ) + + # Step 3: Verify the network still works + LOG.info("Verifying network liveness after mode upgrade") + network.txs.issue(network, number_txs=5) + + # Step 4: Add another COSE node + LOG.info("Adding another COSE node") + another_node = network.create_node() + network.join_node( + another_node, + nargs.package, + nargs, + ledger_signature_mode="COSE", + ) + network.trust_node(another_node, nargs) + + network.txs.issue(network, number_txs=3) + + # Step 5: Verify receipts on all nodes after COSE-only transition + LOG.info("Verifying receipts on all nodes after COSE-only transition") + new_primary, _ = network.find_primary() + post_msg = network.txs.priv[network.txs.idx][0] + + for node in network.get_joined_nodes(): + # Old dual-signed TX should still have a regular receipt + verify_receipt_available( + node, + pre_msg["view"], + pre_msg["seqno"], + service_key, + expect_regular=True, + ) + # New TX on COSE-only network: regular receipt should fail + verify_receipt_available( + node, + post_msg["view"], + post_msg["seqno"], + service_key, + expect_regular=False, + ) + + # Step 6: Verify ledger is COSE-only signed after old nodes removed + LOG.info("Verifying ledger is COSE-only signed after old nodes removed") + network.wait_for_all_nodes_to_commit(primary=new_primary) + cose_sig_count = assert_ledger_cose_only_after( + new_primary, cose_only_start_seqno + ) + + LOG.success( + f"COSE-only mode upgrade test passed. " + f"Found {cose_sig_count} COSE-only signatures after seqno {cose_only_start_seqno}" + ) + + def run_cose_signatures_config_check(args): nargs = copy.deepcopy(args) nargs.nodes = infra.e2e_args.max_nodes(nargs, f=0) @@ -4031,3 +4218,4 @@ def run(args): run_propose_request_vote(args) run_time_based_snapshotting(args) run_snapshot_persistence_across_primary_failure(args) + run_cose_only_mode_upgrade(args) diff --git a/tests/infra/remote.py b/tests/infra/remote.py index b8a7acaa5faa..e6148f049de2 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -334,6 +334,7 @@ def __init__( historical_cache_soft_limit=None, cose_signatures_issuer="service.example.com", cose_signatures_subject="ledger.signature", + ledger_signature_mode="Dual", sealing_recovery_location=None, recovery_decision_protocol_expected_locations=None, backup_snapshot_fetch_enabled=False, @@ -534,6 +535,7 @@ def __init__( historical_cache_soft_limit=historical_cache_soft_limit, cose_signatures_issuer=cose_signatures_issuer, cose_signatures_subject=cose_signatures_subject, + ledger_signature_mode=ledger_signature_mode, sealing_recovery_location=sealing_recovery_location, recovery_decision_protocol_expected_locations=recovery_decision_protocol_expected_locations, backup_snapshot_fetch_enabled=backup_snapshot_fetch_enabled, diff --git a/tests/recovery.py b/tests/recovery.py index 694576243738..389eb1015100 100644 --- a/tests/recovery.py +++ b/tests/recovery.py @@ -187,6 +187,7 @@ def test_recover_service( no_ledger=False, via_recovery_owner=False, force_election=False, + ledger_signature_mode="Dual", ): network.save_service_identity(args) old_primary, _ = network.find_primary() @@ -251,6 +252,7 @@ def test_recover_service( committed_ledger_dirs=committed_ledger_dirs, snapshots_dir=snapshots_dir, service_data_json_file=ntf.name, + ledger_signature_mode=ledger_signature_mode, ) LOG.info("Check that service data has been set") primary, _ = recovered_network.find_primary() @@ -1415,6 +1417,67 @@ def run_recover_via_added_recovery_owner(args): return network +def run_recovery_cose_only(args): + """ + Recover a service that was started with ledger_signature_mode=COSE. + Verifies that the recovered service also uses COSE-only signatures. + """ + + def assert_ledger_cose_only(node): + current_ledger_dir, committed_ledger_dirs = node.get_ledger() + ledger = ccf.ledger.Ledger( + committed_ledger_dirs + [current_ledger_dir], committed_only=False + ) + cose_sig_count = 0 + for chunk in ledger: + for tx in chunk: + tables = tx.get_public_domain().get_tables() + assert ccf.ledger.SIGNATURE_TX_TABLE_NAME not in tables, ( + f"Found traditional signature at seqno {tx.get_public_domain().get_seqno()}, " + f"expected COSE-only for entire ledger" + ) + if ccf.ledger.COSE_SIGNATURE_TX_TABLE_NAME in tables: + cose_sig_count += 1 + assert cose_sig_count > 0, "No COSE signatures found in ledger" + return cose_sig_count + + txs = app.LoggingTxs("user0") + with infra.network.network( + args.nodes, + args.binary_dir, + args.debug_nodes, + pdb=args.pdb, + txs=txs, + ) as network: + network.start_and_open(args, ledger_signature_mode="COSE") + network.txs.issue(network, number_txs=5) + + # Recover in COSE-only mode + network = test_recover_service( + network, args, from_snapshot=True, ledger_signature_mode="COSE" + ) + + # Issue TXs after recovery and verify entire ledger is COSE-only + network.txs.issue(network, number_txs=5) + primary, _ = network.find_primary() + cose_sig_count = assert_ledger_cose_only(primary) + LOG.success( + f"Recovered COSE-only service has {cose_sig_count} COSE-only signatures" + ) + + # Second recovery without snapshot + network = test_recover_service( + network, args, from_snapshot=False, ledger_signature_mode="COSE" + ) + network.txs.issue(network, number_txs=5) + primary, _ = network.find_primary() + cose_sig_count = assert_ledger_cose_only(primary) + LOG.success( + f"Second recovered COSE-only service has {cose_sig_count} COSE-only signatures" + ) + return network + + if __name__ == "__main__": def add(parser): @@ -1537,4 +1600,13 @@ def add(parser): snapshot_tx_interval=10000, ) + cr.add( + "recovery_cose_only", + run_recovery_cose_only, + package="samples/apps/logging/logging", + nodes=infra.e2e_args.min_nodes(cr.args, f=1), + ledger_chunk_bytes="50KB", + snapshot_tx_interval=30, + ) + cr.run()