From 62b8d46e65c6c6bbf3af313b91dcf99bf1f7bebf Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Mon, 5 Oct 2026 19:52:07 +0000 Subject: [PATCH 1/6] Previous servuce signing keys --- CHANGELOG.md | 8 + doc/governance/accept_recovery.rst | 28 +- doc/host_config_schema/host_config.json | 48 +++- include/ccf/node/configuration.h | 21 +- include/ccf/service_signing_keys.h | 18 ++ samples/config/recover_config.json | 9 +- samples/config/start_config.json | 3 + samples/constitutions/default/actions.js | 68 +++++ samples/minimal_ccf/app/actions.js | 68 +++++ src/crypto/openssl/verifier.cpp | 21 ++ src/crypto/openssl/verifier.h | 4 + src/crypto/test/crypto.cpp | 20 ++ src/enclave/enclave.h | 2 + src/enclave/entry_points.h | 2 + src/enclave/main.cpp | 8 +- src/host/run.cpp | 21 +- src/node/gov/extensions/node.cpp | 100 ++++++- src/node/gov/extensions/node.h | 1 + src/node/identity.h | 28 ++ src/node/node_state.h | 219 +++++++++++---- src/node/rpc/gov_effects_interface.h | 6 +- src/node/rpc/test/node_frontend_test.cpp | 47 +++- src/node/snapshot_serdes.h | 45 +++- src/node/startup_inputs.h | 53 +++- src/node/test/identity_types.cpp | 38 +++ src/node/test/snapshotter.cpp | 52 ++++ tests/config.jinja | 6 +- tests/e2e_operations.py | 6 +- tests/infra/consortium.py | 37 ++- tests/infra/network.py | 94 ++++++- tests/infra/remote.py | 14 + tests/partitions_test.py | 6 +- tests/recovery.py | 327 +++++++++++++++++++---- tests/start_network.py | 14 + 34 files changed, 1274 insertions(+), 168 deletions(-) create mode 100644 include/ccf/service_signing_keys.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 37063c3848bb..4842e2920cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [7.0.19]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.19 +### Added + +- The `transition_service_to_open_with_signing_keys` proposal action opens a service with `previous_service_signing_keys` and `next_service_signing_keys`, maps of identity types to PEM public keys, instead of certificates. The public header `ccf/service_signing_keys.h` declares `ccf::ServiceSigningKeys` and `ccf::SigningKeyType`. See [accepting recovery](https://microsoft.github.io/CCF/main/governance/accept_recovery.html) (#8477). + +### Deprecated + +- The `previous_service_identity` argument of the `transition_service_to_open` proposal and the `command.recover.previous_service_identity_file` configuration option are deprecated in favour of the `transition_service_to_open_with_signing_keys` proposal and `command.recover.previous_service_signing_key_files` respectively. In C++, `ccf::CCFConfig::Command::Recover::previous_service_identity_file` is now `std::optional` (#8477). + ### Fixed - Paused RPC reads now resume when another interface releases the inbound budget, even if older libuv versions coalesce the notification. Previously, reads could remain paused until an unrelated event triggered a recheck (#8498). diff --git a/doc/governance/accept_recovery.rst b/doc/governance/accept_recovery.rst index 5ee0e6ce09d9..a69ee943c56a 100644 --- a/doc/governance/accept_recovery.rst +++ b/doc/governance/accept_recovery.rst @@ -84,9 +84,33 @@ A member proposes to recover the network and other members can vote on the propo Once the proposal to recover the network has passed under the rules of the :term:`Constitution`, the recovered service is ready for members to submit their recovery shares. -Note that the ``transition_service_to_open`` proposal takes two parameters: the previous and the next :term:`Service Identity` (X.509 certificates in PEM format). The previous identity must match the identity supplied to the recovery node at startup, while the next identity must match the recovered service's newly generated identity. Snapshot validation is performed earlier at node startup using the configured previous service identity. Since both identities are recorded on the ledger with the proposal, it is always clear at which point the identity changed. +Members can open the recovered service with either of two proposals. ``transition_service_to_open`` takes ``previous_service_identity`` and ``next_service_identity``, PEM certificates. ``transition_service_to_open_with_signing_keys`` takes ``previous_service_signing_keys`` and ``next_service_signing_keys``, JSON objects mapping identity types to PEM public keys, and does not accept certificates. The previous and next values must match the previous service identity recorded in the ledger and the recovered service, respectively. Each key map must contain a ``CLASSICAL`` key, and only ``CLASSICAL`` keys are compared. -.. note:: The ``previous_service_identity`` argument to the ``transition_service_to_open`` proposal is required for recovery, but must not be provided when opening a new service as there is no previous identity. +The ``previous_service_identity`` argument is deprecated, so recovery proposals should use ``transition_service_to_open_with_signing_keys``. These identities are recorded on the ledger with the proposal. + +Each service writes its signing keys to the files configured by ``command.service_signing_key_files``, by default ``service_signing_key_classical.pem``. A ``transition_service_to_open_with_signing_keys`` proposal uses the previous service's file and the recovered service's file: + +.. code-block:: json + + { + "actions": [ + { + "name": "transition_service_to_open_with_signing_keys", + "args": { + "previous_service_signing_keys": { + "CLASSICAL": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" + }, + "next_service_signing_keys": { + "CLASSICAL": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" + } + } + } + ] + } + +Snapshot validation happens earlier at node startup. Operators must supply ``command.recover.previous_service_signing_key_files`` or the deprecated ``command.recover.previous_service_identity_file``. When key files are supplied, they are used for verification even if a certificate is also supplied, with no fallback to the certificate. The recovered service certificate inherits the subject of the previous certificate when ``command.recover.previous_service_identity_file`` is supplied. Otherwise, ``command.recover.service_cert_subject_name`` is required and sets the subject. See :doc:`/operations/configuration`. + +.. note:: Recovery proposals require previous signing keys or a previous service certificate. Neither previous-identity argument is needed when opening a new service. Submitting Recovery Shares -------------------------- diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index 58b132b6bdea..d43c5f16395b 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -169,6 +169,20 @@ "type": "string", "default": "service_cert.pem", "description": "For ``Start`` and ``Recover`` nodes, path to which service certificate will be written to on startup. For ``Join`` nodes, path to the certificate of the existing service to join" + }, + "service_signing_key_files": { + "type": "object", + "default": { "CLASSICAL": "service_signing_key_classical.pem" }, + "properties": { + "CLASSICAL": { + "type": "string", + "minLength": 1, + "description": "Output path for the classical service signing public key (PEM)" + } + }, + "required": ["CLASSICAL"], + "additionalProperties": false, + "description": "For ``Start`` and ``Recover`` nodes, paths to which service signing public keys will be written on startup" } }, "allOf": [ @@ -364,15 +378,43 @@ "description": "Initial validity period (days) for service certificate", "minimum": 1 }, + "service_cert_subject_name": { + "type": "string", + "minLength": 1, + "description": "Subject name for the recovered service certificate when ``previous_service_identity_file`` is not set; otherwise the previous certificate's subject is inherited" + }, "previous_service_identity_file": { "type": "string", - "description": "Path to the previous service certificate (PEM) file" + "minLength": 1, + "description": "Deprecated. Path to the previous service certificate (PEM) file. Use ``previous_service_signing_key_files`` instead" + }, + "previous_service_signing_key_files": { + "type": "object", + "properties": { + "CLASSICAL": { + "type": "string", + "minLength": 1, + "description": "Path to the previous classical service signing public key (PEM)" + } + }, + "required": ["CLASSICAL"], + "additionalProperties": false, + "description": "Paths to the previous service signing public keys" } }, - "required": ["previous_service_identity_file"], + "anyOf": [ + { "required": ["previous_service_identity_file"] }, + { + "required": [ + "previous_service_signing_key_files", + "service_cert_subject_name" + ] + } + ], "additionalProperties": false } - } + }, + "required": ["recover"] } } ], diff --git a/include/ccf/node/configuration.h b/include/ccf/node/configuration.h index 4101d35c2ae2..134ceccde47c 100644 --- a/include/ccf/node/configuration.h +++ b/include/ccf/node/configuration.h @@ -15,7 +15,9 @@ #include "ccf/service/tables/host_data.h" #include "ccf/service/tables/members.h" #include "ccf/service/tables/self_healing_open.h" +#include "ccf/service_signing_keys.h" +#include #include #include #include @@ -225,6 +227,8 @@ namespace ccf { StartType type = StartType::Start; std::string service_certificate_file = "service_cert.pem"; + std::map service_signing_key_files = { + {SigningKeyType::CLASSICAL, "service_signing_key_classical.pem"}}; struct Start { @@ -258,7 +262,11 @@ namespace ccf struct Recover { size_t initial_service_certificate_validity_days = 1; - std::string previous_service_identity_file; + std::optional service_cert_subject_name = std::nullopt; + std::optional previous_service_identity_file = + std::nullopt; + std::optional> + previous_service_signing_key_files = std::nullopt; bool operator==(const Recover&) const = default; }; Recover recover = {}; @@ -412,12 +420,19 @@ namespace ccf DECLARE_JSON_OPTIONAL_FIELDS( CCFConfig::Command::Recover, initial_service_certificate_validity_days, - previous_service_identity_file); + service_cert_subject_name, + previous_service_identity_file, + previous_service_signing_key_files); DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::Command); DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::Command, type); DECLARE_JSON_OPTIONAL_FIELDS( - CCFConfig::Command, service_certificate_file, start, join, recover); + CCFConfig::Command, + service_certificate_file, + service_signing_key_files, + start, + join, + recover); DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig); DECLARE_JSON_REQUIRED_FIELDS(CCFConfig, network, command); diff --git a/include/ccf/service_signing_keys.h b/include/ccf/service_signing_keys.h new file mode 100644 index 000000000000..b2342ba93dd0 --- /dev/null +++ b/include/ccf/service_signing_keys.h @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/crypto/pem.h" + +#include +#include + +namespace ccf +{ + struct SigningKeyType + { + static constexpr auto CLASSICAL = "CLASSICAL"; + }; + + using ServiceSigningKeys = std::map; +} diff --git a/samples/config/recover_config.json b/samples/config/recover_config.json index 7af4249c41c7..a291059db903 100644 --- a/samples/config/recover_config.json +++ b/samples/config/recover_config.json @@ -19,9 +19,16 @@ "command": { "type": "Recover", "service_certificate_file": "service_cert.pem", + "service_signing_key_files": { + "CLASSICAL": "service_signing_key_classical.pem" + }, "recover": { "initial_service_certificate_validity_days": 1, - "previous_service_identity_file": "previous_service_cert.pem" + "service_cert_subject_name": "CN=A Sample CCF Service", + "previous_service_identity_file": "previous_service_cert.pem", + "previous_service_signing_key_files": { + "CLASSICAL": "previous_service_signing_key_classical.pem" + } } }, "ledger": { diff --git a/samples/config/start_config.json b/samples/config/start_config.json index 5ad54cd839f6..b7bba712c981 100644 --- a/samples/config/start_config.json +++ b/samples/config/start_config.json @@ -28,6 +28,9 @@ "command": { "type": "Start", "service_certificate_file": "service_cert.pem", + "service_signing_key_files": { + "CLASSICAL": "service_signing_key_classical.pem" + }, "start": { "constitution_files": [ "validate.js", diff --git a/samples/constitutions/default/actions.js b/samples/constitutions/default/actions.js index 521cde0bcc8f..4c80785cd183 100644 --- a/samples/constitutions/default/actions.js +++ b/samples/constitutions/default/actions.js @@ -414,6 +414,17 @@ function checkX509CertBundle(value, field) { } } +function checkServiceSigningKeys(value, field) { + if (value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + checkType(value, "object", field); + checkType(value.CLASSICAL, "string", `${field}.CLASSICAL (PEM public key)`); + for (const [identityType, key] of Object.entries(value)) { + checkType(key, "string", `${field}.${identityType} (PEM public key)`); + } +} + function invalidateOtherOpenProposals(proposalIdToRetain) { const proposalsMap = ccf.kv["public:ccf.gov.proposals_info"]; proposalsMap.forEach((v, k) => { @@ -935,6 +946,63 @@ const actions = new Map([ }, ), ], + [ + "transition_service_to_open_with_signing_keys", + new Action( + function (args) { + if ( + args.previous_service_identity !== undefined || + args.next_service_identity !== undefined + ) { + throw new Error( + "Service certificates are not accepted, use transition_service_to_open instead", + ); + } + checkServiceSigningKeys( + args.next_service_signing_keys, + "next_service_signing_keys", + ); + if (args.previous_service_signing_keys !== undefined) { + checkServiceSigningKeys( + args.previous_service_signing_keys, + "previous_service_signing_keys", + ); + } + }, + + function (args) { + const service_info = "public:ccf.gov.service.info"; + const rawService = ccf.kv[service_info].get(getSingletonKvKey()); + if (rawService === undefined) { + throw new Error("Service information could not be found"); + } + + const service = ccf.bufToJsonCompatible(rawService); + + if ( + service.status === "Recovering" && + (args.previous_service_signing_keys === undefined || + args.next_service_signing_keys === undefined) + ) { + throw new Error( + `Opening a recovering network requires both, the previous and the next service signing keys`, + ); + } + + const previous_keys = + args.previous_service_signing_keys !== undefined + ? ccf.jsonCompatibleToBuf(args.previous_service_signing_keys) + : undefined; + const next_keys = ccf.jsonCompatibleToBuf( + args.next_service_signing_keys, + ); + ccf.node.transitionServiceToOpenWithSigningKeys( + previous_keys, + next_keys, + ); + }, + ), + ], [ "set_js_app", new Action( diff --git a/samples/minimal_ccf/app/actions.js b/samples/minimal_ccf/app/actions.js index 6a0d563776d0..e2688a3e619e 100644 --- a/samples/minimal_ccf/app/actions.js +++ b/samples/minimal_ccf/app/actions.js @@ -394,6 +394,17 @@ function checkX509CertBundle(value, field) { } } +function checkServiceSigningKeys(value, field) { + if (value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + checkType(value, "object", field); + checkType(value.CLASSICAL, "string", `${field}.CLASSICAL (PEM public key)`); + for (const [identityType, key] of Object.entries(value)) { + checkType(key, "string", `${field}.${identityType} (PEM public key)`); + } +} + function invalidateOtherOpenProposals(proposalIdToRetain) { const proposalsMap = ccf.kv["public:ccf.gov.proposals_info"]; proposalsMap.forEach((v, k) => { @@ -914,6 +925,63 @@ const actions = new Map([ }, ), ], + [ + "transition_service_to_open_with_signing_keys", + new Action( + function (args) { + if ( + args.previous_service_identity !== undefined || + args.next_service_identity !== undefined + ) { + throw new Error( + "Service certificates are not accepted, use transition_service_to_open instead", + ); + } + checkServiceSigningKeys( + args.next_service_signing_keys, + "next_service_signing_keys", + ); + if (args.previous_service_signing_keys !== undefined) { + checkServiceSigningKeys( + args.previous_service_signing_keys, + "previous_service_signing_keys", + ); + } + }, + + function (args) { + const service_info = "public:ccf.gov.service.info"; + const rawService = ccf.kv[service_info].get(getSingletonKvKey()); + if (rawService === undefined) { + throw new Error("Service information could not be found"); + } + + const service = ccf.bufToJsonCompatible(rawService); + + if ( + service.status === "Recovering" && + (args.previous_service_signing_keys === undefined || + args.next_service_signing_keys === undefined) + ) { + throw new Error( + `Opening a recovering network requires both, the previous and the next service signing keys`, + ); + } + + const previous_keys = + args.previous_service_signing_keys !== undefined + ? ccf.jsonCompatibleToBuf(args.previous_service_signing_keys) + : undefined; + const next_keys = ccf.jsonCompatibleToBuf( + args.next_service_signing_keys, + ); + ccf.node.transitionServiceToOpenWithSigningKeys( + previous_keys, + next_keys, + ); + }, + ), + ], [ "set_js_app", new Action( diff --git a/src/crypto/openssl/verifier.cpp b/src/crypto/openssl/verifier.cpp index 8b6a893a9245..5a5562d8345f 100644 --- a/src/crypto/openssl/verifier.cpp +++ b/src/crypto/openssl/verifier.cpp @@ -182,6 +182,27 @@ namespace ccf::crypto return valid; } + bool Verifier_OpenSSL::verify_certificate_signature( + const Pem& signing_key) const + { + Unique_BIO key_bio(signing_key); + Unique_PKEY key(key_bio); + const auto rc = X509_verify(cert, key); + if (rc < 0) + { + throw std::runtime_error(fmt::format( + "OpenSSL certificate signature verification error: {}", + OpenSSL::first_error())); + } + if (rc == 0) + { + LOG_DEBUG_FMT( + "Certificate signature does not match the trusted public key: {}", + OpenSSL::first_error()); + } + return rc == 1; + } + bool Verifier_OpenSSL::is_self_signed() const { return (X509_get_extension_flags(cert) & EXFLAG_SS) != 0U; diff --git a/src/crypto/openssl/verifier.h b/src/crypto/openssl/verifier.h index 5941b642284f..e8c0b0591a62 100644 --- a/src/crypto/openssl/verifier.h +++ b/src/crypto/openssl/verifier.h @@ -29,6 +29,10 @@ namespace ccf::crypto const std::vector& chain = {}, bool ignore_time = false) override; + // Verifies only the certificate signature, with a trusted public key + [[nodiscard]] bool verify_certificate_signature( + const Pem& signing_key) const; + bool is_self_signed() const override; std::string serial_number() const override; diff --git a/src/crypto/test/crypto.cpp b/src/crypto/test/crypto.cpp index c8cb8a7d9027..09da19464d3c 100644 --- a/src/crypto/test/crypto.cpp +++ b/src/crypto/test/crypto.cpp @@ -277,6 +277,26 @@ TEST_CASE("Verifier rejects unsupported public key type") make_verifier(cert_pem), "unsupported public key type", std::logic_error); } +TEST_CASE("Verifier checks a certificate signature with a trusted public key") +{ + const auto issuer = make_ec_key_pair(); + const auto issuer_cert = generate_self_signed_cert(issuer, "CN=issuer"); + const auto subject = make_ec_key_pair(); + const auto cert = create_endorsed_cert( + subject->public_key_pem(), + "CN=subject", + {}, + make_verifier(issuer_cert)->validity_period(), + issuer->private_key_pem(), + issuer_cert); + const Verifier_OpenSSL verifier(cert.raw()); + + CHECK(verifier.verify_certificate_signature(issuer->public_key_pem())); + CHECK_FALSE(verifier.verify_certificate_signature(subject->public_key_pem())); + CHECK_THROWS( + std::ignore = verifier.verify_certificate_signature(issuer_cert)); +} + TEST_CASE("Private PEM imports enforce key family") { const auto ec = make_ec_key_pair(); diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 300912d9abb1..572652756ff4 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -212,6 +212,7 @@ namespace ccf ccf::CCFConfig ccf_config_, std::vector& node_cert, std::vector& service_cert, + ServiceSigningKeys& service_signing_keys, std::vector& rpc_addresses) { start_type = start_type_; @@ -353,6 +354,7 @@ namespace ccf // When starting a node in start or recover modes, fresh network secrets // are created and the associated certificate can be passed to the host service_cert = create_info.service_cert.raw(); + service_signing_keys = std::move(create_info.service_signing_keys); } return CreateNodeStatus::OK; diff --git a/src/enclave/entry_points.h b/src/enclave/entry_points.h index d0309ddb219f..c3905c489fc7 100644 --- a/src/enclave/entry_points.h +++ b/src/enclave/entry_points.h @@ -4,6 +4,7 @@ #include "ccf/node/configuration.h" #include "ccf/node/start_type.h" +#include "ccf/service_signing_keys.h" #include "common/configuration.h" #include "common/enclave_interface_types.h" #include "ds/work_beacon.h" @@ -22,6 +23,7 @@ namespace ccf const ccf::CCFConfig& ccf_config, std::vector& node_cert, std::vector& service_cert, + ServiceSigningKeys& service_signing_keys, std::vector& rpc_addresses, StartType start_type, ccf::LoggerLevel log_level, diff --git a/src/enclave/main.cpp b/src/enclave/main.cpp index 2cc9e1fd8110..df94beb52974 100644 --- a/src/enclave/main.cpp +++ b/src/enclave/main.cpp @@ -30,6 +30,7 @@ namespace ccf const ccf::CCFConfig& ccf_config, std::vector& node_cert, std::vector& service_cert, + ServiceSigningKeys& service_signing_keys, std::vector& rpc_addresses, StartType start_type, ccf::LoggerLevel log_level, @@ -145,7 +146,12 @@ namespace ccf try { status = enclave->create_new_node( - start_type, ccf_config, node_cert, service_cert, rpc_addresses); + start_type, + ccf_config, + node_cert, + service_cert, + service_signing_keys, + rpc_addresses); } catch (...) { diff --git a/src/host/run.cpp b/src/host/run.cpp index 433b65ecf529..23ad548a9eb3 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -226,6 +226,7 @@ namespace ccf EnclaveConfig& enclave_config, std::vector& node_cert, std::vector& service_cert, + ServiceSigningKeys& service_signing_keys, std::vector& rpc_addresses, ccf::LoggerLevel log_level, ringbuffer::NotifyingWriterFactory& notifying_factory, @@ -249,6 +250,7 @@ namespace ccf config, node_cert, service_cert, + service_signing_keys, rpc_addresses, config.command.type, log_level, @@ -291,10 +293,11 @@ namespace ccf return std::nullopt; } - void write_certificates_to_disk( + void write_identity_files_to_disk( const ccf::CCFConfig& config, const std::vector& node_cert, - const std::vector& service_cert) + const std::vector& service_cert, + const ServiceSigningKeys& service_signing_keys) { // Write the node and service certs to disk. files::dump(node_cert, config.output_files.node_certificate_file); @@ -310,6 +313,14 @@ namespace ccf LOG_INFO_FMT( "Output service certificate to {}", config.command.service_certificate_file); + for (const auto& [identity_type, public_key] : service_signing_keys) + { + const auto& path = + config.command.service_signing_key_files.at(identity_type); + files::dump(public_key.raw(), path); + LOG_INFO_FMT( + "Output {} service signing public key to {}", identity_type, path); + } } } @@ -498,6 +509,7 @@ namespace ccf const size_t certificate_size = 4096; std::vector node_cert(certificate_size); std::vector service_cert(certificate_size); + ServiceSigningKeys service_signing_keys; std::vector rpc_addresses; if (ccf::pal::platform == ccf::pal::Platform::Virtual) @@ -552,6 +564,7 @@ namespace ccf enclave_config, node_cert, service_cert, + service_signing_keys, rpc_addresses, log_level, factories.notifying_factory, @@ -563,8 +576,8 @@ namespace ccf return enclave_creation_result; } - // Output certificates to disk - write_certificates_to_disk(config, node_cert, service_cert); + write_identity_files_to_disk( + config, node_cert, service_cert, service_signing_keys); // Run enclave threads and event loop run_enclave_threads(config, *runtime_control); diff --git a/src/node/gov/extensions/node.cpp b/src/node/gov/extensions/node.cpp index 52f3b2bba3e9..4ea6b6ecc414 100644 --- a/src/node/gov/extensions/node.cpp +++ b/src/node/gov/extensions/node.cpp @@ -78,6 +78,27 @@ namespace ccf::js::extensions return ccf::js::core::constants::Undefined; } + // Returns false if value is neither undefined nor an array buffer + bool get_service_signing_keys( + JSContext* ctx, + JSValueConst value, + std::optional& keys) + { + if (JS_IsUndefined(value) != 0) + { + return true; + } + size_t size = 0; + const auto* bytes = JS_GetArrayBuffer(ctx, &size, value); + if (bytes == nullptr) + { + return false; + } + keys = + ccf::parse_json_safe(bytes, bytes + size).get(); + return true; + } + JSValue js_node_transition_service_to_open( JSContext* ctx, [[maybe_unused]] JSValueConst this_val, @@ -147,7 +168,78 @@ namespace ccf::js::extensions } identities.next = ccf::crypto::Pem(next_bytes, next_bytes_sz); - GOV_DEBUG_FMT("next service identity: {}", identities.next.str()); + GOV_DEBUG_FMT("next service identity: {}", identities.next->str()); + + gov_effects->transition_service_to_open(*tx_ptr, identities); + } + catch (const std::exception& e) + { + GOV_FAIL_FMT("Unable to open service: {}", e.what()); + return JS_ThrowInternalError( + ctx, "Unable to open service: %s", e.what()); + } + + return ccf::js::core::constants::Undefined; + } + + JSValue js_node_transition_service_to_open_with_signing_keys( + JSContext* ctx, + [[maybe_unused]] JSValueConst this_val, + int argc, + [[maybe_unused]] JSValueConst* argv) + { + js::core::Context& jsctx = + *reinterpret_cast(JS_GetContextOpaque(ctx)); + + if (argc != 2) + { + return JS_ThrowTypeError( + ctx, "Passed %d arguments but expected two", argc); + } + + auto* extension = jsctx.get_extension(); + if (extension == nullptr) + { + return JS_ThrowInternalError(ctx, "Failed to get extension object"); + } + + auto* gov_effects = extension->gov_effects; + if (gov_effects == nullptr) + { + return JS_ThrowInternalError( + ctx, "Failed to get governance effects object"); + } + + auto* tx_ptr = extension->tx; + if (tx_ptr == nullptr) + { + return JS_ThrowInternalError(ctx, "Failed to get tx object"); + } + + try + { + AbstractGovernanceEffects::ServiceIdentities identities; + + if (!get_service_signing_keys( + ctx, argv[0], identities.previous_signing_keys)) + { + return JS_ThrowTypeError( + ctx, + "Previous service signing keys argument is not an array buffer"); + } + + if (JS_IsUndefined(argv[1]) != 0) + { + return JS_ThrowInternalError( + ctx, "Proposal requires the next service signing keys"); + } + + if (!get_service_signing_keys( + ctx, argv[1], identities.next_signing_keys)) + { + return JS_ThrowTypeError( + ctx, "Next service signing keys argument is not an array buffer"); + } gov_effects->transition_service_to_open(*tx_ptr, identities); } @@ -362,6 +454,12 @@ namespace ccf::js::extensions "transitionServiceToOpen", ctx.new_c_function( js_node_transition_service_to_open, "transitionServiceToOpen", 2))); + JS_CHECK_OR_THROW(node.set( + "transitionServiceToOpenWithSigningKeys", + ctx.new_c_function( + js_node_transition_service_to_open_with_signing_keys, + "transitionServiceToOpenWithSigningKeys", + 2))); JS_CHECK_OR_THROW(node.set( "triggerRecoverySharesRefresh", ctx.new_c_function( diff --git a/src/node/gov/extensions/node.h b/src/node/gov/extensions/node.h index 9f6099053f1a..4e71c58d31a7 100644 --- a/src/node/gov/extensions/node.h +++ b/src/node/gov/extensions/node.h @@ -12,6 +12,7 @@ namespace ccf::js::extensions * * - ccf.node.triggerLedgerRekey * - ccf.node.transitionServiceToOpen + * - ccf.node.transitionServiceToOpenWithSigningKeys * - ccf.node.triggerRecoverySharesRefresh * - ccf.node.triggerLedgerChunk * - ccf.node.triggerSnapshot diff --git a/src/node/identity.h b/src/node/identity.h index 54fca5ae1aa5..803f40a972e9 100644 --- a/src/node/identity.h +++ b/src/node/identity.h @@ -5,15 +5,43 @@ #include "ccf/cose_signatures_config.h" #include "ccf/crypto/curve.h" #include "ccf/crypto/verifier.h" +#include "ccf/service_signing_keys.h" #include "crypto/certs.h" #include "crypto/openssl/ec_key_pair.h" +#include #include +#include +#include #include #include namespace ccf { + inline ccf::crypto::ECPublicKeyPtr get_previous_service_classical_signing_key( + const std::optional& keys, + const std::optional>& certificate) + { + if (keys.has_value()) + { + if (!keys->contains(SigningKeyType::CLASSICAL)) + { + throw std::logic_error(fmt::format( + "Missing {} previous service signing public key", + SigningKeyType::CLASSICAL)); + } + return ccf::crypto::make_ec_public_key( + keys->at(SigningKeyType::CLASSICAL)); + } + + if (!certificate.has_value()) + { + throw std::logic_error("No previous service identity is configured"); + } + return ccf::crypto::make_ec_public_key( + ccf::crypto::make_unique_verifier(*certificate)->public_key_der()); + } + struct NetworkIdentity { ccf::crypto::Pem priv_key; diff --git a/src/node/node_state.h b/src/node/node_state.h index c8f388c6bbbf..087a4f752729 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -22,6 +22,7 @@ #include "ccf/service/node_info_network.h" #include "ccf/service/tables/self_healing_open.h" #include "ccf/service/tables/service.h" +#include "ccf/service_signing_keys.h" #include "ccf/tx.h" #include "consensus/aft/raft.h" #include "consensus/ledger_enclave.h" @@ -97,6 +98,7 @@ namespace ccf { ccf::crypto::Pem self_signed_node_cert; ccf::crypto::Pem service_cert; + ServiceSigningKeys service_signing_keys; }; inline void reset_data(std::vector& data) @@ -564,13 +566,10 @@ namespace ccf void verify_recovery_snapshot_candidate_unsafe( const SnapshotSegments& segments, ccf::kv::Version snapshot_seqno) { - if (!startup_inputs.previous_service_identity.has_value()) - { - throw std::logic_error("No previous service identity is configured"); - } - - const ccf::crypto::Pem target_identity( - *startup_inputs.previous_service_identity); + const auto target_key = get_previous_service_classical_signing_key( + startup_inputs.previous_service_signing_keys, + startup_inputs.previous_service_identity) + ->public_key_der(); verify_snapshot_seqno( segments, network.tables->get_encryptor(), snapshot_seqno); @@ -578,7 +577,10 @@ namespace ccf { try { - verify_snapshot(segments, target_identity.raw()); + verify_snapshot( + segments, + startup_inputs.previous_service_identity, + startup_inputs.previous_service_signing_keys); LOG_INFO_FMT( "Recovery snapshot at {} is directly signed by the configured " "previous service identity", @@ -599,7 +601,7 @@ namespace ccf try { const auto verifier = - ccf::crypto::make_cose_verifier_from_pem_cert(target_identity); + ccf::crypto::make_cose_verifier_from_key(target_key); if (verifier->verify_detached(segments.receipt, receipt.merkle_root)) { LOG_INFO_FMT( @@ -626,8 +628,6 @@ namespace ccf const auto scan = scan_recovery_snapshot_ledger_files( config.ledger, network.tables->get_encryptor(), snapshot_seqno); - const auto target_key = ccf::crypto::public_key_der_from_cert( - ccf::crypto::cert_pem_to_der(target_identity)); const auto snapshot_signer_key = validate_recovery_snapshot_endorsement_chain( scan.endorsements, target_key, snapshot_seqno); @@ -1324,7 +1324,7 @@ namespace ccf initiate_quote_generation(); LOG_INFO_FMT("Created new node {}", self); - return {new_self_signed_node_cert, network.identity->cert}; + break; } case StartType::Join: { @@ -1339,24 +1339,17 @@ namespace ccf initiate_quote_generation(); LOG_INFO_FMT("Created join node {}", self); - return {new_self_signed_node_cert, {}}; + return {new_self_signed_node_cert, {}, {}}; } case StartType::Recover: { LOG_INFO_FMT("Creating new node - recover"); - // Already enforced by resolve_startup_inputs(); kept as a guard for - // the dereference below, with the same message. - if (!startup_inputs.previous_service_identity.has_value()) - { - throw std::logic_error( - "Recovery requires the certificate of the previous service " - "identity"); - } - ccf::crypto::Pem previous_service_identity_cert( - startup_inputs.previous_service_identity.value()); + get_previous_service_classical_signing_key( + startup_inputs.previous_service_signing_keys, + startup_inputs.previous_service_identity); network.identity = std::make_unique( - ccf::crypto::get_subject_name(previous_service_identity_cert), + startup_inputs.service_cert_subject_name, curve_id, startup_time, config.command.recover.initial_service_certificate_validity_days); @@ -1364,7 +1357,7 @@ namespace ccf initiate_quote_generation(); LOG_INFO_FMT("Created recovery node {}", self); - return {new_self_signed_node_cert, network.identity->cert}; + break; } default: { @@ -1372,6 +1365,14 @@ namespace ccf fmt::format("Node was started in unknown mode {}", start_type)); } } + + ServiceSigningKeys service_signing_keys{ + {SigningKeyType::CLASSICAL, + network.identity->get_key_pair()->public_key_pem()}}; + return { + new_self_signed_node_cert, + network.identity->cert, + std::move(service_signing_keys)}; } // @@ -2634,6 +2635,18 @@ namespace ccf // independently synchronised (share_manager, via LedgerSecrets), or // copied out under recovery_secrets_lock. + const bool with_signing_keys = + identities.previous_signing_keys.has_value() || + identities.next_signing_keys.has_value(); + if ( + with_signing_keys && + (identities.previous.has_value() || identities.next.has_value())) + { + throw std::logic_error( + "transition_service_to_open accepts either service certificates or " + "service signing keys, not a mix of both"); + } + auto* service = tx.rw(Tables::SERVICE); auto service_info = service->get(); if (!service_info.has_value()) @@ -2654,40 +2667,13 @@ namespace ccf return; } - if (service_info->status == ServiceStatus::RECOVERING) + if (with_signing_keys) { - const auto prev_ident = - tx.ro(Tables::PREVIOUS_SERVICE_IDENTITY) - ->get(); - if (!prev_ident.has_value() || !identities.previous.has_value()) - { - throw std::logic_error( - "Recovery with service certificates requires both, a previous " - "service identity written to the KV during recovery genesis and a " - "transition_service_to_open proposal that contains previous and " - "next service certificates"); - } - - const ccf::crypto::Pem from_proposal( - identities.previous->data(), identities.previous->size()); - if (prev_ident.value() != from_proposal) - { - throw std::logic_error(fmt::format( - "Previous service identity does not match.\nActual:\n{}\nIn " - "proposal:\n{}", - prev_ident->str(), - from_proposal.str())); - } + check_signing_keys_to_open(tx, service_info.value(), identities); } - - if (identities.next != service_info->cert) + else { - throw std::logic_error(fmt::format( - "Service identity mismatch: the next service identity in the " - "transition_service_to_open proposal does not match the current " - "service identity:\nNext:\n{}\nCurrent:\n{}", - identities.next.str(), - service_info->cert.str())); + check_certificates_to_open(tx, service_info.value(), identities); } if (is_part_of_public_network()) @@ -2758,6 +2744,127 @@ namespace ccf } private: + // Checks the identities in a transition_service_to_open proposal + static void check_certificates_to_open( + ccf::kv::Tx& tx, + const ServiceInfo& service_info, + const AbstractGovernanceEffects::ServiceIdentities& identities) + { + if (service_info.status == ServiceStatus::RECOVERING) + { + const auto prev_ident = + tx.ro(Tables::PREVIOUS_SERVICE_IDENTITY) + ->get(); + if (!prev_ident.has_value() || !identities.previous.has_value()) + { + throw std::logic_error( + "Recovery with service certificates requires both, a previous " + "service identity written to the KV during recovery genesis and a " + "transition_service_to_open proposal that contains previous and " + "next service certificates"); + } + + const ccf::crypto::Pem from_proposal( + identities.previous->data(), identities.previous->size()); + if (prev_ident.value() != from_proposal) + { + throw std::logic_error(fmt::format( + "Previous service identity does not match.\nActual:\n{}\nIn " + "proposal:\n{}", + prev_ident->str(), + from_proposal.str())); + } + } + + if (!identities.next.has_value()) + { + throw std::logic_error( + "transition_service_to_open requires the next service certificate"); + } + if (identities.next.value() != service_info.cert) + { + throw std::logic_error(fmt::format( + "Service identity mismatch: the next service identity in the " + "transition_service_to_open proposal does not match the current " + "service identity:\nNext:\n{}\nCurrent:\n{}", + identities.next->str(), + service_info.cert.str())); + } + } + + static std::vector classical_signing_key_der( + const ServiceSigningKeys& keys, const char* name) + { + const auto key = keys.find(SigningKeyType::CLASSICAL); + if (key == keys.end()) + { + throw std::logic_error(fmt::format( + "Missing {} {} service signing key", + SigningKeyType::CLASSICAL, + name)); + } + return ccf::crypto::make_ec_public_key(key->second)->public_key_der(); + } + + // Checks the identities in a transition_service_to_open_with_signing_keys + // proposal. Only CLASSICAL keys are compared. + static void check_signing_keys_to_open( + ccf::kv::Tx& tx, + const ServiceInfo& service_info, + const AbstractGovernanceEffects::ServiceIdentities& identities) + { + if (service_info.status == ServiceStatus::RECOVERING) + { + const auto prev_ident = + tx.ro(Tables::PREVIOUS_SERVICE_IDENTITY) + ->get(); + if ( + !prev_ident.has_value() || + !identities.previous_signing_keys.has_value()) + { + throw std::logic_error( + "Recovery with service signing keys requires both, a previous " + "service identity written to the KV during recovery genesis and a " + "transition_service_to_open_with_signing_keys proposal that " + "contains previous and next service signing keys"); + } + + const auto expected_key = ccf::crypto::public_key_der_from_cert( + ccf::crypto::cert_pem_to_der(prev_ident.value())); + if ( + classical_signing_key_der( + identities.previous_signing_keys.value(), "previous") != + expected_key) + { + throw std::logic_error(fmt::format( + "Previous service identity does not match.\nActual:\n{}\nIn " + "proposal:\n{}", + prev_ident->str(), + nlohmann::json(identities.previous_signing_keys.value()).dump())); + } + } + + if (!identities.next_signing_keys.has_value()) + { + throw std::logic_error( + "transition_service_to_open_with_signing_keys requires the next " + "service signing keys"); + } + const auto& next_keys = identities.next_signing_keys.value(); + const auto current_key = + get_service_signing_identity(tx, IdentityType::CLASSICAL); + if ( + !current_key.has_value() || + classical_signing_key_der(next_keys, "next") != current_key->value) + { + throw std::logic_error(fmt::format( + "Service identity mismatch: the next service signing keys in the " + "transition_service_to_open_with_signing_keys proposal do not " + "match the current service signing keys:\nNext:\n{}", + nlohmann::json(next_keys).dump())); + } + } + // Copies of the recovery state protected by recovery_secrets_lock. These // return by value so that callers never hold the mutex while touching the // KV store, which would invert the KV locks -> recovery_secrets_lock order diff --git a/src/node/rpc/gov_effects_interface.h b/src/node/rpc/gov_effects_interface.h index f940d9b540f6..60d350ef69c1 100644 --- a/src/node/rpc/gov_effects_interface.h +++ b/src/node/rpc/gov_effects_interface.h @@ -4,6 +4,7 @@ #include "ccf/crypto/pem.h" #include "ccf/node_subsystem_interface.h" +#include "ccf/service_signing_keys.h" #include "ccf/tx.h" namespace ccf @@ -18,10 +19,13 @@ namespace ccf return "GovernanceEffects"; } + // Either certificates or signing keys, never a mix of both struct ServiceIdentities { std::optional previous; - ccf::crypto::Pem next; + std::optional next; + std::optional previous_signing_keys = std::nullopt; + std::optional next_signing_keys = std::nullopt; }; virtual void transition_service_to_open( diff --git a/src/node/rpc/test/node_frontend_test.cpp b/src/node/rpc/test/node_frontend_test.cpp index b08da3b78ec6..3bc0a2634c92 100644 --- a/src/node/rpc/test/node_frontend_test.cpp +++ b/src/node/rpc/test/node_frontend_test.cpp @@ -135,6 +135,7 @@ TEST_CASE("Node configuration retains operator file paths") {"host_data_transparent_statement_path", "not-loaded/statement.cose"}}}, {"recover", {{"previous_service_identity_file", "not-loaded/previous.pem"}, + {"service_cert_subject_name", "CN=Recovered Service"}, {"initial_service_certificate_validity_days", 13}}}}}}; auto config = input.get(); @@ -189,6 +190,8 @@ TEST_CASE("Node configuration retains operator file paths") config.command.recover.previous_service_identity_file == "not-loaded/previous.pem"); CHECK(config.command.recover.initial_service_certificate_validity_days == 13); + CHECK( + config.command.recover.service_cert_subject_name == "CN=Recovered Service"); const auto defaults = json{ {"network", CCFConfig{}.network}, @@ -199,6 +202,7 @@ TEST_CASE("Node configuration retains operator file paths") CHECK(defaults.command.join.fetch_recent_snapshot); CHECK( defaults.command.recover.initial_service_certificate_validity_days == 1); + CHECK_FALSE(defaults.command.recover.service_cert_subject_name.has_value()); } TEST_CASE("Genesis request retains resolved data on the wire") @@ -345,6 +349,7 @@ TEST_CASE("Startup inputs are read from files") TEST_CASE("Startup inputs are resolved by start type") { const ScopedTempDir dir; + const auto previous_subject = ccf::crypto::get_subject_name(member_cert); const auto missing_file = (dir.path / "missing").string(); const auto bytes = [](const std::string& s) { return std::vector(s.begin(), s.end()); @@ -369,7 +374,7 @@ TEST_CASE("Startup inputs are resolved by start type") config.command.start.constitution_files = { write_test_file(dir, "constitution.js", "constitution")}; config.command.recover.previous_service_identity_file = - write_test_file(dir, "previous_identity.pem", "previous identity"); + write_test_file(dir, "previous_identity.pem", member_cert.str()); { INFO("Start reads node data, service data and genesis inputs"); @@ -390,7 +395,40 @@ TEST_CASE("Startup inputs are resolved by start type") CHECK(inputs.service_data == json{{"service", 2}}); CHECK_FALSE(inputs.genesis_info.has_value()); CHECK(inputs.join_service_cert.empty()); - CHECK(inputs.previous_service_identity == bytes("previous identity")); + CHECK(inputs.previous_service_identity == member_cert.raw()); + CHECK(inputs.service_cert_subject_name == previous_subject); + } + + { + INFO("A supplied certificate provides the recovery subject"); + auto with_subject = config; + with_subject.command.recover.service_cert_subject_name = + "CN=Different Service"; + CHECK( + resolve(with_subject, StartType::Recover).service_cert_subject_name == + previous_subject); + } + + { + INFO("Key-only recovery requires the configured subject"); + auto keys_only = config; + keys_only.command.recover.previous_service_identity_file.reset(); + keys_only.command.recover.previous_service_signing_key_files = + std::map{ + {SigningKeyType::CLASSICAL, + write_test_file(dir, "previous_key.pem", kp->public_key_pem().str())}}; + keys_only.command.recover.service_cert_subject_name = + "CN=Recovered Service"; + const auto inputs = resolve(keys_only, StartType::Recover); + CHECK_FALSE(inputs.previous_service_identity.has_value()); + CHECK(inputs.service_cert_subject_name == "CN=Recovered Service"); + + keys_only.command.recover.service_cert_subject_name.reset(); + CHECK( + logic_error_message( + [&]() { resolve_startup_inputs(keys_only, StartType::Recover); }) == + "Recovery without command.recover.previous_service_identity_file " + "requires command.recover.service_cert_subject_name"); } { @@ -410,11 +448,12 @@ TEST_CASE("Startup inputs are resolved by start type") { INFO("Inputs required by the start type must be readable"); auto no_identity = config; - no_identity.command.recover.previous_service_identity_file = ""; + no_identity.command.recover.previous_service_identity_file.reset(); CHECK( logic_error_message( [&]() { resolve_startup_inputs(no_identity, StartType::Recover); }) == - "Recovery requires the certificate of the previous service identity"); + "Recovery requires previous service signing keys or a previous service " + "certificate"); auto no_service_cert = config; no_service_cert.command.service_certificate_file = missing_file; diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 9a032e483c90..43401cb86a0d 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -9,12 +9,14 @@ #include "ccf/historical_queries_adapter.h" #include "ccf/service/tables/nodes.h" #include "crypto/cose.h" +#include "crypto/openssl/verifier.h" #include "ds/internal_logger.h" #include "ds/serialized.h" #include "kv/kv_types.h" #include "kv/serialised_entry_format.h" #include "node/cose_common.h" #include "node/history.h" +#include "node/identity.h" #include "node/rpc/network_identity_chain_helpers.h" #include "node/tx_receipt_impl.h" @@ -254,14 +256,18 @@ namespace ccf static void verify_cose_snapshot_receipt( const SnapshotSegments& segments, - const std::optional>& prev_service_identity) + const std::optional>& prev_service_identity, + const std::optional& prev_service_signing_keys = + std::nullopt) { const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); - if (prev_service_identity) + if (prev_service_signing_keys || prev_service_identity) { - auto verifier = ccf::crypto::make_cose_verifier_from_pem_cert( - ccf::crypto::Pem(*prev_service_identity)); + const auto key = get_previous_service_classical_signing_key( + prev_service_signing_keys, prev_service_identity); + auto verifier = + ccf::crypto::make_cose_verifier_from_key(key->public_key_der()); if (!verifier->verify_detached(segments.receipt, receipt.merkle_root)) { throw std::logic_error( @@ -274,7 +280,9 @@ namespace ccf static void verify_json_snapshot_receipt( const SnapshotSegments& segments, - const std::optional>& prev_service_identity) + const std::optional>& prev_service_identity, + const std::optional& prev_service_signing_keys = + std::nullopt) { auto j = ccf::parse_json_safe(segments.receipt.begin(), segments.receipt.end()); @@ -299,7 +307,8 @@ namespace ccf auto root = receipt->calculate_root(); - auto v = ccf::crypto::make_unique_verifier(receipt->cert); + auto v = + std::make_unique(receipt->cert.raw()); if (!v->verify_hash( root.h.data(), root.h.size(), @@ -311,7 +320,19 @@ namespace ccf "Signature verification failed for snapshot receipt"); } - if (prev_service_identity) + if (prev_service_signing_keys) + { + const auto key = get_previous_service_classical_signing_key( + prev_service_signing_keys, prev_service_identity); + if (!v->verify_certificate_signature(key->public_key_pem())) + { + throw std::logic_error( + "Previous service identity does not endorse the node identity " + "that signed the snapshot"); + } + LOG_DEBUG_FMT("Previous service signing key endorses snapshot signer"); + } + else if (prev_service_identity) { ccf::crypto::Pem prev_pem(*prev_service_identity); if (!v->verify_certificate( @@ -328,7 +349,9 @@ namespace ccf static void verify_snapshot( const SnapshotSegments& segments, - std::optional> prev_service_identity = std::nullopt) + std::optional> prev_service_identity = std::nullopt, + const std::optional& prev_service_signing_keys = + std::nullopt) { LOG_INFO_FMT( "Deserialising snapshot receipt (size: {}).", segments.receipt.size()); @@ -359,12 +382,14 @@ namespace ccf if (first_byte == ENCODED_COSE_SIGN1_TAG) { LOG_DEBUG_FMT("Snapshot with COSE receipt detected"); - verify_cose_snapshot_receipt(segments, prev_service_identity); + verify_cose_snapshot_receipt( + segments, prev_service_identity, prev_service_signing_keys); } else if (first_byte == '{') { LOG_DEBUG_FMT("Snapshot with JSON receipt detected"); - verify_json_snapshot_receipt(segments, prev_service_identity); + verify_json_snapshot_receipt( + segments, prev_service_identity, prev_service_signing_keys); } else { diff --git a/src/node/startup_inputs.h b/src/node/startup_inputs.h index e628a3432392..e22bf62b7c5f 100644 --- a/src/node/startup_inputs.h +++ b/src/node/startup_inputs.h @@ -3,9 +3,11 @@ #pragma once #include "ccf/crypto/pem.h" +#include "ccf/crypto/verifier.h" #include "ccf/ds/json.h" #include "ccf/node/configuration.h" #include "ccf/node/start_type.h" +#include "ccf/service_signing_keys.h" #include "ds/internal_logger.h" #include "node/rpc/node_call_types.h" @@ -119,7 +121,7 @@ namespace ccf return genesis; } - // File-backed inputs from the operator configuration, other than the SNP + // Resolved startup inputs from the operator configuration, other than the SNP // attestation files (read during quote generation) and the join transparent // statement (read on each join attempt). struct StartupInputs @@ -133,8 +135,11 @@ namespace ccf // Join only std::vector join_service_cert; // Recover only + std::string service_cert_subject_name; std::optional> previous_service_identity = std::nullopt; + std::optional previous_service_signing_keys = + std::nullopt; }; // Reads each input required by start_type exactly once, throwing if any @@ -180,17 +185,49 @@ namespace ccf { const auto& identity_file = config.command.recover.previous_service_identity_file; - if (identity_file.empty()) + const auto& key_files = + config.command.recover.previous_service_signing_key_files; + if (!identity_file.has_value() && !key_files.has_value()) { throw std::logic_error( - "Recovery requires the certificate of the previous service " - "identity"); + "Recovery requires previous service signing keys or a previous " + "service certificate"); } - LOG_INFO_FMT( - "Reading previous service identity from {}", identity_file); - inputs.previous_service_identity = - read_startup_file(identity_file, "previous service identity"); + if (identity_file.has_value()) + { + LOG_INFO_FMT( + "Reading previous service identity from {}", *identity_file); + inputs.previous_service_identity = + read_startup_file(*identity_file, "previous service identity"); + // The recovered service certificate inherits the previous subject + inputs.service_cert_subject_name = ccf::crypto::get_subject_name( + ccf::crypto::Pem(*inputs.previous_service_identity)); + } + else + { + const auto& configured_subject = + config.command.recover.service_cert_subject_name; + if (!configured_subject.has_value()) + { + throw std::logic_error( + "Recovery without command.recover.previous_service_identity_file " + "requires command.recover.service_cert_subject_name"); + } + inputs.service_cert_subject_name = configured_subject.value(); + } + if (key_files.has_value()) + { + auto& keys = inputs.previous_service_signing_keys.emplace(); + const auto& path = key_files->at(SigningKeyType::CLASSICAL); + LOG_INFO_FMT( + "Reading previous CLASSICAL service signing public key from {}", + path); + keys.emplace( + SigningKeyType::CLASSICAL, + ccf::crypto::Pem(read_startup_file( + path, "previous CLASSICAL service signing public key"))); + } break; } default: diff --git a/src/node/test/identity_types.cpp b/src/node/test/identity_types.cpp index 74f3d96e5b70..0bf9bbaa3c03 100644 --- a/src/node/test/identity_types.cpp +++ b/src/node/test/identity_types.cpp @@ -4,6 +4,9 @@ #include "service/tables/identity_types.h" #include "ccf/kv/unit.h" +#include "ccf/node/configuration.h" +#include "ccf/service_signing_keys.h" +#include "crypto/openssl/ec_key_pair.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include @@ -93,3 +96,38 @@ TEST_CASE("Identities round-trips through JSON") const nlohmann::json j = identities; REQUIRE(j.get() == identities); } + +TEST_CASE("Service signing key files are a JSON object keyed by identity name") +{ + const ccf::CCFConfig::Command command; + const nlohmann::json paths = command.service_signing_key_files; + REQUIRE( + paths == + nlohmann::json{{"CLASSICAL", "service_signing_key_classical.pem"}}); + + const nlohmann::json custom = { + {"type", "Start"}, + {"service_signing_key_files", {{"CLASSICAL", "custom_signing_key.pem"}}}}; + const auto parsed = custom.get(); + REQUIRE( + parsed.service_signing_key_files.at(ccf::SigningKeyType::CLASSICAL) == + "custom_signing_key.pem"); + const nlohmann::json round_trip = parsed; + REQUIRE( + round_trip.at("service_signing_key_files") == + custom.at("service_signing_key_files")); +} + +TEST_CASE( + "Service signing public keys round-trip as PEM strings in a JSON object") +{ + const ccf::crypto::ECKeyPair_OpenSSL key_pair( + ccf::crypto::CurveID::SECP384R1); + const auto public_key = key_pair.public_key_pem(); + const ccf::ServiceSigningKeys keys{ + {ccf::SigningKeyType::CLASSICAL, public_key}}; + + const nlohmann::json j = keys; + REQUIRE(j == nlohmann::json{{"CLASSICAL", public_key.str()}}); + REQUIRE(j.get() == keys); +} diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index f4761a4483b8..929d052edb5c 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -3,6 +3,10 @@ #include "node/snapshotter.h" +#include "ccf/ds/x509_time_fmt.h" +#include "ccf/receipt.h" +#include "ccf/service/tables/nodes.h" +#include "ccf/service_signing_keys.h" #include "crypto/openssl/hash.h" #include "ds/files.h" #include "ds/internal_logger.h" @@ -153,6 +157,54 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") scan.endorsements, target_key, 1)); } +TEST_CASE("Legacy JSON snapshot receipts are verified with signing keys") +{ + using namespace std::literals; + const auto valid_from = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 1h); + const auto valid_to = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() + 1h); + + const auto service_kp = ccf::crypto::make_ec_key_pair(); + const auto service_cert = + service_kp->self_sign("CN=service", valid_from, valid_to); + const auto signer_kp = ccf::crypto::make_ec_key_pair(); + const std::vector snapshot = {1, 2, 3}; + + auto receipt = std::make_shared(); + receipt->cert = service_kp->sign_csr( + service_cert, signer_kp->create_csr("CN=node"), valid_from, valid_to); + receipt->node_id = ccf::compute_node_id_from_kp(signer_kp); + receipt->leaf_components.write_set_digest = + ccf::crypto::Sha256Hash(std::string("write set")); + receipt->leaf_components.commit_evidence = "ce:2.4:abcd"; + receipt->leaf_components.claims_digest.set( + ccf::crypto::Sha256Hash(snapshot.data(), snapshot.size())); + const auto root = receipt->calculate_root(); + receipt->signature = signer_kp->sign_hash(root.h.data(), root.h.size()); + + const auto receipt_str = nlohmann::json(ccf::ReceiptPtr(receipt)).dump(); + const std::vector receipt_bytes( + receipt_str.begin(), receipt_str.end()); + const ccf::SnapshotSegments segments{snapshot, receipt_bytes}; + + const ccf::ServiceSigningKeys service_keys{ + {ccf::SigningKeyType::CLASSICAL, service_kp->public_key_pem()}}; + const ccf::ServiceSigningKeys other_keys{ + {ccf::SigningKeyType::CLASSICAL, + ccf::crypto::make_ec_key_pair()->public_key_pem()}}; + + REQUIRE_NOTHROW(ccf::verify_snapshot(segments, std::nullopt, service_keys)); + REQUIRE_THROWS_WITH( + ccf::verify_snapshot(segments, std::nullopt, other_keys), + "Previous service identity does not endorse the node identity that " + "signed the snapshot"); + + INFO("Mismatching keys do not fall back to a matching certificate"); + REQUIRE_THROWS( + ccf::verify_snapshot(segments, service_cert.raw(), other_keys)); +} + TEST_CASE("Recovery snapshot endorsement scan bounds candidate endorsements") { ScopedSnapshotDir ledger_dir; diff --git a/tests/config.jinja b/tests/config.jinja index d4bba84898a1..654c3591e0b8 100644 --- a/tests/config.jinja +++ b/tests/config.jinja @@ -49,8 +49,10 @@ "host_data_transparent_statement_path": {{ host_data_transparent_statement_path|tojson }}{% endif %} }, "recover": { - "initial_service_certificate_validity_days": {{ initial_service_cert_validity_days }}, - "previous_service_identity_file": "{{ previous_service_identity_file }}" + "initial_service_certificate_validity_days": {{ initial_service_cert_validity_days }}{% if recovery_service_cert_subject_name is defined and recovery_service_cert_subject_name is not none %}, + "service_cert_subject_name": {{ recovery_service_cert_subject_name|tojson }}{% endif %}{% if previous_service_identity_file is defined and previous_service_identity_file is not none %}, + "previous_service_identity_file": {{ previous_service_identity_file|tojson }}{% endif %}{% if previous_service_signing_key_files is defined and previous_service_signing_key_files is not none %}, + "previous_service_signing_key_files": {{ previous_service_signing_key_files|tojson }}{% endif %} } }, "ledger": diff --git a/tests/e2e_operations.py b/tests/e2e_operations.py index 0c61e5076608..1cae2f4f2fe1 100644 --- a/tests/e2e_operations.py +++ b/tests/e2e_operations.py @@ -2561,7 +2561,8 @@ def run_initial_uvm_descriptor_checks(const_args): ) network.consortium.add_snp_uvm_endorsement(primary, did, feed, bumped_svn) - network_service_identity_file, _ = network.save_service_identity_to_file() + network.save_service_identity(args) + network_service_identity_file = args.previous_service_identity_file snapshots_dir = network.get_committed_snapshots(primary) network.stop_all_nodes() LOG.info("Check that the a UVM descriptor is present") @@ -2680,7 +2681,8 @@ def get_min_tcb_versions(node): tcb_versions_before_recovery[cpuid]["hexstring"] == tcb_hex_before_recovery ), tcb_versions_before_recovery - network_service_identity_file, _ = network.save_service_identity_to_file() + network.save_service_identity(args) + network_service_identity_file = args.previous_service_identity_file snapshots_dir = network.get_committed_snapshots(primary) network.stop_all_nodes() diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index b4ef3549179a..17c4a5eef6d7 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -497,6 +497,13 @@ def add_user(self, remote_node, user_id, user_data=None): def get_service_identity(self): return slurp_file(os.path.join(self.common_dir, "service_cert.pem")) + def get_service_signing_keys(self): + return { + "CLASSICAL": slurp_file( + os.path.join(self.common_dir, "service_signing_key_classical.pem") + ) + } + def add_users_and_transition_service_to_open(self, remote_node, users): proposal = {"actions": []} for user_id in users: @@ -719,7 +726,12 @@ def remove_ca_cert_bundle(self, remote_node, cert_name): proposal = self.get_any_active_member().propose(remote_node, proposal_body) return self.vote_using_majority(remote_node, proposal, careful_vote) - def transition_service_to_open(self, remote_node, previous_service_identity=None): + def transition_service_to_open( + self, + remote_node, + previous_service_identity=None, + previous_service_signing_keys=None, + ): """ Assuming a network in state OPENING, this functions creates a new proposal and make members vote to transition the network to state @@ -731,16 +743,25 @@ def transition_service_to_open(self, remote_node, previous_service_identity=None if r.body.json()["state"] == infra.node.State.PART_OF_NETWORK.value: is_recovery = False + action = "transition_service_to_open" args = {} if CCFVersion(remote_node.version) > CCFVersion("ccf-2.0.0-rc3"): - args = { - "previous_service_identity": previous_service_identity, - "next_service_identity": self.get_service_identity(), - } + if ( + remote_node.version is None + and previous_service_signing_keys is not None + ): + action = "transition_service_to_open_with_signing_keys" + args = { + "previous_service_signing_keys": previous_service_signing_keys, + "next_service_signing_keys": self.get_service_signing_keys(), + } + else: + args = { + "previous_service_identity": previous_service_identity, + "next_service_identity": self.get_service_identity(), + } - proposal_body, careful_vote = self.make_proposal( - "transition_service_to_open", **args - ) + proposal_body, careful_vote = self.make_proposal(action, **args) proposal = self.get_any_active_member().propose(remote_node, proposal_body) self.vote_using_majority( diff --git a/tests/infra/network.py b/tests/infra/network.py index b6ebf175001a..f9bf5cdfd2db 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -20,6 +20,7 @@ import ccf.ledger from ccf.tx_id import TxID from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from cryptography.x509 import load_pem_x509_certificate from loguru import logger as LOG @@ -31,6 +32,7 @@ import infra.openapi import infra.path import infra.proc +import infra.remote from infra.clients import CCFConnectionException, CCFIOException, flush_info from infra.consortium import slurp_file from infra.node import CCFVersion @@ -45,6 +47,56 @@ COMMON_FOLDER = "common" +def get_previous_service_identity(args): + certificate_file = getattr(args, "previous_service_identity_file", None) + key_files = getattr(args, "previous_service_signing_key_files", None) + return { + "previous_service_identity": ( + slurp_file(certificate_file) if certificate_file else None + ), + "previous_service_signing_keys": ( + { + identity_type: slurp_file(path) + for identity_type, path in key_files.items() + } + if key_files is not None + else None + ), + } + + +def service_signing_key_from_certificate(certificate): + return ( + load_pem_x509_certificate(certificate.encode("ascii"), default_backend()) + .public_key() + .public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) + .decode("ascii") + ) + + +def save_service_signing_keys(certificate_file, directory, key_files=None): + if key_files is None: + # Historical services did not export signing public keys separately. + keys = { + "CLASSICAL": service_signing_key_from_certificate( + slurp_file(certificate_file) + ) + } + else: + keys = { + identity_type: slurp_file(path) for identity_type, path in key_files.items() + } + + stem = os.path.splitext(os.path.basename(certificate_file))[0] + paths = {} + for identity_type, key in keys.items(): + path = os.path.join(directory, f"{stem}_{identity_type}_pubk.pem") + with open(path, "w", encoding="utf-8") as key_file: + key_file.write(key) + paths[identity_type] = path + return paths + + class NodeRole(Enum): ANY = auto() PRIMARY = auto() @@ -216,6 +268,8 @@ class Network: "config_file", "ubsan_options", "previous_service_identity_file", + "previous_service_signing_key_files", + "recovery_service_cert_subject_name", "snp_endorsements_servers", "node_to_node_message_limit", "historical_cache_soft_limit", @@ -982,16 +1036,9 @@ def recover( # so we make sure that we're running the right one. self.consortium.set_constitution(random_node, args.constitution) - prev_service_identity = None - if ( - args.previous_service_identity_file is not None - and args.previous_service_identity_file != "" - ): - prev_service_identity = slurp_file(args.previous_service_identity_file) - self.consortium.transition_service_to_open( self.find_random_node(), - previous_service_identity=prev_service_identity, + **get_previous_service_identity(args), ) if via_local_sealing: @@ -2500,7 +2547,10 @@ def verify_service_certificate_validity_period(self, expected_validity_days): def refresh_service_identity_file(self, args): """ Refresh service_cert.pem from the current primary node, so that future client - connections pick up the new service certificate. + connections pick up the new service certificate. The service signing key files + are copied from the joined node that started or recovered the service, as the + files fetched on startup may come from a recovery node whose identity was not + retained. """ primary = self.find_random_node() with primary.client(verify_ca=False) as c: @@ -2516,6 +2566,20 @@ def refresh_service_identity_file(self, args): with open(identity_filepath, "w", encoding="utf-8") as f: f.write(new_service_identity) + exporters = [ + node + for node in self.get_joined_nodes() + if node.remote.start_type + in {infra.remote.StartType.start, infra.remote.StartType.recover} + ] + assert len(exporters) == 1, [node.local_node_id for node in exporters] + (exporter,) = exporters + if exporter.remote.supports_service_signing_keys: + exporter.remote.get_service_signing_key_files(self.common_dir) + LOG.info( + f"Refreshed service signing key files from node {exporter.local_node_id}" + ) + def get_service_identity(self): n = self.find_random_node() with n.client() as c: @@ -2542,6 +2606,18 @@ def save_service_identity_to_file(self): def save_service_identity(self, args): path, identity = self.save_service_identity_to_file() args.previous_service_identity_file = path + signing_key_file = os.path.join( + self.common_dir, "service_signing_key_classical.pem" + ) + args.previous_service_signing_key_files = save_service_signing_keys( + path, + self.common_dir, + ( + {"CLASSICAL": signing_key_file} + if os.path.exists(signing_key_file) + else None + ), + ) return identity def identity(self, name=None): diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 6aaaacfbd5a2..80ba40f2c555 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -552,6 +552,7 @@ def __init__( self.name = f"{label}_{local_node_id}" self.start_type = start_type + self.supports_service_signing_keys = version is None self.local_node_id = local_node_id self.pem = f"{local_node_id}.pem" self.node_address_file = f"{local_node_id}.node_address" @@ -741,6 +742,14 @@ def __init__( # This will also ensure the render produced valid JSON j = json.loads(output) + if not self.supports_service_signing_keys: + j["command"].get("recover", {}).pop( + "previous_service_signing_key_files", None + ) + j["command"].get("recover", {}).pop( + "service_cert_subject_name", None + ) + # 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(): @@ -860,6 +869,11 @@ def get_startup_files(self, dst_path, timeout=FILE_TIMEOUT_S): self.remote.get(self.rpc_addresses_file, dst_path, timeout=timeout) if self.start_type in {StartType.start, StartType.recover}: self.remote.get("service_cert.pem", dst_path, timeout=timeout) + if self.supports_service_signing_keys: + self.get_service_signing_key_files(dst_path, timeout=timeout) + + def get_service_signing_key_files(self, dst_path, timeout=FILE_TIMEOUT_S): + self.remote.get("service_signing_key_classical.pem", dst_path, timeout=timeout) def debug_node_cmd(self): return self.remote.debug_node_cmd() diff --git a/tests/partitions_test.py b/tests/partitions_test.py index 8e789e997393..766893fb10e2 100644 --- a/tests/partitions_test.py +++ b/tests/partitions_test.py @@ -1016,7 +1016,7 @@ def test_recovery_elections(orig_network, args): r = c.get("/node/network") assert r.status_code == 200, r - previous_identity = orig_network.save_service_identity(args) + orig_network.save_service_identity(args) c.wait_for_commit( orig_network.consortium.set_recovery_threshold(old_primary, 1) ) @@ -1041,11 +1041,11 @@ def test_recovery_elections(orig_network, args): ) new_primary, new_backups = network.find_nodes() network.consortium.transition_service_to_open( - new_primary, previous_service_identity=previous_identity + new_primary, **infra.network.get_previous_service_identity(args) ) with new_primary.client("user0") as c: - previous_identity = network.save_service_identity(args) + network.save_service_identity(args) member = network.consortium.get_active_recovery_participants()[0] diff --git a/tests/recovery.py b/tests/recovery.py index 7602b12c8111..44c163919187 100644 --- a/tests/recovery.py +++ b/tests/recovery.py @@ -41,7 +41,6 @@ test_cose_receipt_schema, verify_receipt, ) -from infra.consortium import slurp_file from infra.runner import ConcurrentRunner from loguru import logger as LOG from reconfiguration import assert_no_ipv4_in_node_configs @@ -256,14 +255,9 @@ def recover_with_primary_dying(args, recovered_network, after_backups_recovered= recovered_network.find_random_node() ) - prev_service_identity = None - if args.previous_service_identity_file: - prev_service_identity = slurp_file(args.previous_service_identity_file) - LOG.info(f"Prev identity: {prev_service_identity}") - recovered_network.consortium.transition_service_to_open( recovered_network.find_random_node(), - previous_service_identity=prev_service_identity, + **infra.network.get_previous_service_identity(args), ) retired_primary, initial_view = recovered_network.find_primary() @@ -443,7 +437,7 @@ def test_recovery_member_changes_rejected_during_recovery(network, args): primary, _ = recovered_network.find_primary() recovered_network.consortium.transition_service_to_open( primary, - previous_service_identity=slurp_file(args.previous_service_identity_file), + **infra.network.get_previous_service_identity(args), ) recovered_network.consortium.check_for_service( primary, @@ -556,9 +550,7 @@ def run_reconfiguration_before_recovery_shares(args): primary, _ = recovered_network.find_primary() recovered_network.consortium.transition_service_to_open( primary, - previous_service_identity=slurp_file( - args.previous_service_identity_file - ), + **infra.network.get_previous_service_identity(args), ) recovered_network.consortium.check_for_service( primary, infra.network.ServiceStatus.WAITING_FOR_RECOVERY_SHARES @@ -638,6 +630,7 @@ def test_recover_service( isolate_latest_snapshot=False, election_after_backups_recovered=False, recovered_networks=None, + signing_keys_only=False, ): if not from_snapshot and snapshots_dir is not None: raise ValueError("snapshots_dir requires from_snapshot=True") @@ -680,6 +673,7 @@ def test_recover_service( snapshots_dir=isolated_snapshots_dir, election_after_backups_recovered=election_after_backups_recovered, recovered_networks=recovered_networks, + signing_keys_only=signing_keys_only, ) return _recover_service( @@ -692,6 +686,7 @@ def test_recover_service( snapshots_dir=snapshots_dir, election_after_backups_recovered=election_after_backups_recovered, recovered_networks=recovered_networks, + signing_keys_only=signing_keys_only, ) @@ -711,6 +706,70 @@ def test_recover_service_with_ledger_after_snapshot(network, args): return test_recover_service(network, args, snapshots_dir=snapshots_dir) +def check_signing_keys_proposal_rejections(network, args, previous_identity): + """ + Signing key proposals carrying certificates are not created, and mismatching + signing keys fail when applied, leaving the service recovering. + """ + primary, _ = network.find_primary() + consortium = network.consortium + action = "transition_service_to_open_with_signing_keys" + previous_keys = infra.network.get_previous_service_identity(args)[ + "previous_service_signing_keys" + ] + next_keys = consortium.get_service_signing_keys() + + for certificate_args in ( + {"next_service_identity": consortium.get_service_identity()}, + {"previous_service_identity": previous_identity}, + ): + body, _ = consortium.make_proposal( + action, + previous_service_signing_keys=previous_keys, + next_service_signing_keys=next_keys, + **certificate_args, + ) + try: + consortium.get_any_active_member().propose(primary, body) + assert False, f"Proposal should not be created: {list(certificate_args)}" + except infra.proposal.ProposalNotCreated as e: + assert e.response.status_code == http.HTTPStatus.BAD_REQUEST, e.response + + for mismatching_args, expected_error in ( + ( + { + "previous_service_signing_keys": next_keys, + "next_service_signing_keys": next_keys, + }, + "Previous service identity does not match", + ), + ( + { + "previous_service_signing_keys": previous_keys, + "next_service_signing_keys": previous_keys, + }, + ( + "the next service signing keys in the " + "transition_service_to_open_with_signing_keys proposal do not match" + ), + ), + ): + body, ballot = consortium.make_proposal(action, **mismatching_args) + proposal = consortium.get_any_active_member().propose(primary, body) + try: + consortium.vote_using_majority(primary, proposal, ballot) + assert False, "Mismatching proposal should not be accepted" + except infra.proposal.ProposalNotAccepted as e: + assert ( + e.response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR + ), e.response + assert ( + expected_error in e.response.body.json()["error"]["message"] + ), e.response + + consortium.check_for_service(primary, infra.network.ServiceStatus.RECOVERING) + + def _recover_service( network, args, @@ -721,6 +780,7 @@ def _recover_service( snapshots_dir=None, election_after_backups_recovered=False, recovered_networks=None, + signing_keys_only=False, ): network.save_service_identity(args) old_node_ids = {node.node_id for node in network.get_joined_nodes()} @@ -770,6 +830,14 @@ def _recover_service( else: current_ledger_dir, committed_ledger_dirs = old_primary.get_ledger() + if signing_keys_only: + # Without the previous certificate, the recovered service certificate + # subject must be configured explicitly + args.previous_service_identity_file = None + args.recovery_service_cert_subject_name = load_pem_x509_certificate( + prev_ident.encode("ascii"), default_backend() + ).subject.rfc4514_string() + with tempfile.NamedTemporaryFile(mode="w+") as node_data_tf: start_node_data = {"this is a": "recovery node"} json.dump(start_node_data, node_data_tf) @@ -834,6 +902,9 @@ def _recover_service( r = c.get("/node/ready/app") assert r.status_code == http.HTTPStatus.SERVICE_UNAVAILABLE.value, r + if signing_keys_only: + check_signing_keys_proposal_rejections(recovered_network, args, prev_ident) + if force_election: recover_with_primary_dying( args, @@ -905,6 +976,17 @@ def _recover_service( unexpected_removable_node_ids = old_node_ids & removable_node_ids assert not unexpected_removable_node_ids, unexpected_removable_node_ids + if signing_keys_only: + recovered_cert = load_pem_x509_certificate( + current_network_info["service_certificate"].encode("ascii"), + default_backend(), + ) + assert ( + recovered_cert.subject.rfc4514_string() + == args.recovery_service_cert_subject_name + ), recovered_cert.subject + args.recovery_service_cert_subject_name = None + return recovered_network @@ -1008,51 +1090,61 @@ def test_recover_service_with_wrong_identity(network, args): current_ledger_dir, committed_ledger_dirs = old_primary.get_ledger() - # Attempt a recovery with the wrong previous service certificate - # The mismatch results in all snapshots being ignored - - args.previous_service_identity_file = network.consortium.user_cert_path("user0") - - broken_network = infra.network.Network( - args.nodes, - args.binary_dir, - args.debug_nodes, - existing_network=network, - ) + # Each wrong previous identity results in all snapshots being ignored, and + # the mismatch is only fatal when used in a transition proposal. Invalid + # signing keys must not fall back to the correct previous certificate. + wrong_identity_file = network.consortium.user_cert_path("user0") + for identity_file, signing_key_files in ( + (wrong_identity_file, None), + ( + first_service_identity_file, + infra.network.save_service_signing_keys( + wrong_identity_file, network.common_dir + ), + ), + ): + args.previous_service_identity_file = identity_file + args.previous_service_signing_key_files = signing_key_files - broken_network.start_in_recovery( - args, - ledger_dir=current_ledger_dir, - committed_ledger_dirs=committed_ledger_dirs, - snapshots_dir=snapshots_dir, - ) + broken_network = infra.network.Network( + args.nodes, + args.binary_dir, + args.debug_nodes, + existing_network=network, + ) - # The mismatch is only fatal when used in a transition proposal - exception = None - try: - broken_network.recover(args) - except Exception as ex: - exception = ex + broken_network.start_in_recovery( + args, + ledger_dir=current_ledger_dir, + committed_ledger_dirs=committed_ledger_dirs, + snapshots_dir=snapshots_dir, + ) - broken_network.ignoring_shutdown_errors = True - broken_network.stop_all_nodes(skip_verification=True) + exception = None + try: + broken_network.recover(args) + except Exception as ex: + exception = ex - if exception is None: - raise ValueError("Recovery should have failed") + broken_network.ignoring_shutdown_errors = True + broken_network.stop_all_nodes(skip_verification=True) - if not broken_network.nodes[0].check_log_for_error_message( - "Previous service identity does not match the service identity that signed the snapshot" - ): - raise ValueError("Node log does not contain the expected error message") + if exception is None: + raise ValueError("Recovery should have failed") - if not broken_network.nodes[0].check_log_for_error_message( - "Unable to open service: Previous service identity does not match." - ): - raise ValueError("Node log does not contain the expected error message") + for message in ( + "Previous service identity does not match the service identity that signed the snapshot", + "Unable to open service: Previous service identity does not match.", + ): + if not broken_network.nodes[0].check_log_for_error_message(message): + raise ValueError( + f"Node log does not contain the expected error message: {message}" + ) - # Recover, now with the correct service identity + # Recover, now with the correct previous service certificate only args.previous_service_identity_file = first_service_identity_file + args.previous_service_signing_key_files = None recovered_network = infra.network.Network( args.nodes, @@ -1298,6 +1390,11 @@ def run_recover_service_from_files( args.previous_service_identity_file = os.path.join( old_common, "service_cert.pem" ) + args.previous_service_signing_key_files = ( + infra.network.save_service_signing_keys( + args.previous_service_identity_file, new_common + ) + ) network.start_in_recovery( args, @@ -1558,7 +1655,7 @@ def test_share_resilience(network, args, from_snapshot=False): primary, _ = recovered_network.find_primary() recovered_network.consortium.transition_service_to_open( primary, - previous_service_identity=slurp_file(args.previous_service_identity_file), + **infra.network.get_previous_service_identity(args), ) # Submit all required recovery shares minus one. Last recovery share is @@ -1849,9 +1946,12 @@ def run(args, ipv6=False): network, args, from_snapshot=False ) else: - # Vary nodes certificate elliptic curve + # Vary nodes certificate elliptic curve, and recover from the + # previous service signing keys only args.curve_id = infra.network.EllipticCurve.secp256r1 - network = test_recover_service(network, args, from_snapshot=False) + network = test_recover_service( + network, args, from_snapshot=False, signing_keys_only=True + ) for node in network.get_joined_nodes(): node.verify_certificate_validity_period() @@ -2128,6 +2228,122 @@ def run_recover_snapshot_alone(args): return network +def run_recovery_with_signing_keys_only(args): + """ + Open a new service with transition_service_to_open_with_signing_keys, after + malformed signing key maps are rejected. Then recover it with previous + signing keys only: a wrong key ignores the snapshot and cannot open the + service, while the right key uses the snapshot and opens it. + """ + with infra.network.network( + args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb + ) as network: + network.start(args) + primary, _ = network.find_primary() + consortium = network.consortium + consortium.activate(primary) + member = consortium.get_any_active_member() + action = "transition_service_to_open_with_signing_keys" + next_keys = consortium.get_service_signing_keys() + + for malformed_keys in ( + None, + "not an object", + [next_keys["CLASSICAL"]], + {}, + {"CLASSICAL": 42}, + ): + body = { + "actions": [ + { + "name": action, + "args": {"next_service_signing_keys": malformed_keys}, + } + ] + } + try: + member.propose(primary, body) + assert False, f"Proposal should not be created: {malformed_keys}" + except infra.proposal.ProposalNotCreated as e: + assert e.response.status_code == http.HTTPStatus.BAD_REQUEST, e.response + + body, ballot = consortium.make_proposal( + action, next_service_signing_keys={"CLASSICAL": "not a PEM key"} + ) + proposal = member.propose(primary, body) + try: + consortium.vote_using_majority(primary, proposal, ballot) + assert False, "Proposal with a non-PEM key should not be accepted" + except infra.proposal.ProposalNotAccepted as e: + assert ( + "PEM constructed with non-PEM data" + in e.response.body.json()["error"]["message"] + ), e.response + + body, ballot = consortium.make_proposal( + action, next_service_signing_keys=next_keys + ) + proposal = member.propose(primary, body) + consortium.vote_using_majority(primary, proposal, ballot) + consortium.check_for_service(primary, infra.network.ServiceStatus.OPEN) + + snapshots_dir = network.get_committed_snapshots(primary) + previous_identity = network.save_service_identity(args) + signing_key_files = args.previous_service_signing_key_files + args.previous_service_identity_file = None + args.recovery_service_cert_subject_name = load_pem_x509_certificate( + previous_identity.encode("ascii"), default_backend() + ).subject.rfc4514_string() + network.stop_all_nodes() + ledger_dir, committed_ledger_dirs = primary.get_ledger() + + args.previous_service_signing_key_files = ( + infra.network.save_service_signing_keys( + os.path.join(network.common_dir, f"{member.local_id}_cert.pem"), + network.common_dir, + ) + ) + broken_network = infra.network.Network( + args.nodes, args.binary_dir, args.debug_nodes, existing_network=network + ) + try: + broken_network.start_in_recovery( + args, + ledger_dir=ledger_dir, + committed_ledger_dirs=committed_ledger_dirs, + snapshots_dir=snapshots_dir, + ) + broken_network.recover(args) + assert False, "Recovery with a wrong previous signing key should fail" + except infra.proposal.ProposalNotAccepted: + pass + finally: + broken_network.ignoring_shutdown_errors = True + broken_network.stop_all_nodes(skip_verification=True) + for message in ( + "Previous service identity does not match the service identity that signed the snapshot", + "Unable to open service: Previous service identity does not match.", + ): + assert broken_network.nodes[0].check_log_for_error_message(message), message + + args.previous_service_signing_key_files = signing_key_files + recovered_network = infra.network.Network( + args.nodes, args.binary_dir, args.debug_nodes, existing_network=network + ) + with infra.network.close_on_error(recovered_network): + recovered_network.start_in_recovery( + args, + ledger_dir=ledger_dir, + committed_ledger_dirs=committed_ledger_dirs, + snapshots_dir=snapshots_dir, + ) + assert recovered_network.nodes[0].check_log_for_error_message( + "is directly signed by the configured previous service identity" + ) + recovered_network.recover(args) + recovered_network.stop_all_nodes() + + def run_recovery_with_missing_service_data(args): """ Recovery nodes read their file-backed inputs when they are created, so a @@ -2518,6 +2734,9 @@ def run_recovery_after_cose_upgrade(args): strict_args.previous_service_identity_file = ( recovered_args.previous_service_identity_file ) + strict_args.previous_service_signing_key_files = ( + recovered_args.previous_service_signing_key_files + ) strict_network = infra.network.Network( args.nodes, args.binary_dir, @@ -3363,6 +3582,14 @@ def add(parser): nodes=infra.e2e_args.min_nodes(cr.args, f=0), # 1 node suffices for recovery ) + cr.add( + "recovery_signing_keys_only", + run_recovery_with_signing_keys_only, + package="samples/apps/logging/logging", + nodes=infra.e2e_args.min_nodes(cr.args, f=0), # 1 node suffices for recovery + snapshot_tx_interval=10, + ) + cr.add( "recovery_expired_node_certificate_snapshot", run_recover_snapshot_from_expired_node_certificate, diff --git a/tests/start_network.py b/tests/start_network.py index a1ddc71068ff..8a1ea147c2b4 100644 --- a/tests/start_network.py +++ b/tests/start_network.py @@ -128,6 +128,20 @@ def run(args): LOG.warning(f"Storing previous service's cert at {backup_location}") shutil.copy(previous_service_cert, backup_location) args.previous_service_identity_file = backup_location + signing_key_file = os.path.join( + args.common_dir, "service_signing_key_classical.pem" + ) + args.previous_service_signing_key_files = ( + infra.network.save_service_signing_keys( + backup_location, + args.common_dir, + ( + {"CLASSICAL": signing_key_file} + if os.path.exists(signing_key_file) + else None + ), + ) + ) network.start_in_recovery( args, From 6c155740690776f4bda98eba2cba94d24b8cddea Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Tue, 6 Oct 2026 09:55:08 +0000 Subject: [PATCH 2/6] Address lts version arg --- tests/infra/consortium.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index 17c4a5eef6d7..17ff79e35693 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -747,7 +747,7 @@ def transition_service_to_open( args = {} if CCFVersion(remote_node.version) > CCFVersion("ccf-2.0.0-rc3"): if ( - remote_node.version is None + CCFVersion(remote_node.version) > CCFVersion("ccf-7.0.18") and previous_service_signing_keys is not None ): action = "transition_service_to_open_with_signing_keys" From 5eb8077eb522f90b92dba761d1ca1863361a9bb3 Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Tue, 6 Oct 2026 10:53:59 +0000 Subject: [PATCH 3/6] COSE receipts unit test + small key change --- src/node/identity.h | 50 +++++++++++++++++------ src/node/node_state.h | 14 +++++-- src/node/snapshot_serdes.h | 38 +++++------------- src/node/test/snapshotter.cpp | 76 +++++++++++++++++++++++++++++++++-- 4 files changed, 129 insertions(+), 49 deletions(-) diff --git a/src/node/identity.h b/src/node/identity.h index 803f40a972e9..e6a04c092e0b 100644 --- a/src/node/identity.h +++ b/src/node/identity.h @@ -18,28 +18,54 @@ namespace ccf { - inline ccf::crypto::ECPublicKeyPtr get_previous_service_classical_signing_key( + inline ServiceSigningKeys service_signing_keys_from_certificate( + const std::vector& certificate) + { + return { + {SigningKeyType::CLASSICAL, + ccf::crypto::make_unique_verifier(certificate)->public_key_pem()}}; + } + + // Signing keys take precedence over the deprecated previous service + // certificate, whose public key is used when no keys are configured. + inline std::optional + resolve_previous_service_signing_keys( const std::optional& keys, const std::optional>& certificate) { if (keys.has_value()) { - if (!keys->contains(SigningKeyType::CLASSICAL)) - { - throw std::logic_error(fmt::format( - "Missing {} previous service signing public key", - SigningKeyType::CLASSICAL)); - } - return ccf::crypto::make_ec_public_key( - keys->at(SigningKeyType::CLASSICAL)); + return keys; } + if (certificate.has_value()) + { + return service_signing_keys_from_certificate(*certificate); + } + return std::nullopt; + } - if (!certificate.has_value()) + inline ccf::crypto::ECPublicKeyPtr get_previous_service_classical_signing_key( + const std::optional& keys) + { + if (!keys.has_value()) { throw std::logic_error("No previous service identity is configured"); } - return ccf::crypto::make_ec_public_key( - ccf::crypto::make_unique_verifier(*certificate)->public_key_der()); + if (!keys->contains(SigningKeyType::CLASSICAL)) + { + throw std::logic_error(fmt::format( + "Missing {} previous service signing public key", + SigningKeyType::CLASSICAL)); + } + return ccf::crypto::make_ec_public_key(keys->at(SigningKeyType::CLASSICAL)); + } + + inline ccf::crypto::ECPublicKeyPtr get_previous_service_classical_signing_key( + const std::optional& keys, + const std::optional>& certificate) + { + return get_previous_service_classical_signing_key( + resolve_previous_service_signing_keys(keys, certificate)); } struct NetworkIdentity diff --git a/src/node/node_state.h b/src/node/node_state.h index 087a4f752729..5b192df9e629 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -207,7 +207,8 @@ namespace ccf { const auto segments = separate_segments(latest_peer_snapshot->snapshot_data); - verify_snapshot(segments, service_cert); + verify_snapshot( + segments, service_signing_keys_from_certificate(service_cert)); } catch (const std::exception& e) { @@ -579,8 +580,9 @@ namespace ccf { verify_snapshot( segments, - startup_inputs.previous_service_identity, - startup_inputs.previous_service_signing_keys); + resolve_previous_service_signing_keys( + startup_inputs.previous_service_signing_keys, + startup_inputs.previous_service_identity)); LOG_INFO_FMT( "Recovery snapshot at {} is directly signed by the configured " "previous service identity", @@ -695,7 +697,11 @@ namespace ccf try { const auto segments = separate_segments(snapshot_data); - verify_snapshot(segments, startup_inputs.previous_service_identity); + verify_snapshot( + segments, + resolve_previous_service_signing_keys( + startup_inputs.previous_service_signing_keys, + startup_inputs.previous_service_identity)); } catch (const std::exception& e) { diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 43401cb86a0d..19f0e3eab343 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -256,16 +256,14 @@ namespace ccf static void verify_cose_snapshot_receipt( const SnapshotSegments& segments, - const std::optional>& prev_service_identity, - const std::optional& prev_service_signing_keys = - std::nullopt) + const std::optional& prev_service_signing_keys) { const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); - if (prev_service_signing_keys || prev_service_identity) + if (prev_service_signing_keys) { - const auto key = get_previous_service_classical_signing_key( - prev_service_signing_keys, prev_service_identity); + const auto key = + get_previous_service_classical_signing_key(prev_service_signing_keys); auto verifier = ccf::crypto::make_cose_verifier_from_key(key->public_key_der()); if (!verifier->verify_detached(segments.receipt, receipt.merkle_root)) @@ -280,9 +278,7 @@ namespace ccf static void verify_json_snapshot_receipt( const SnapshotSegments& segments, - const std::optional>& prev_service_identity, - const std::optional& prev_service_signing_keys = - std::nullopt) + const std::optional& prev_service_signing_keys) { auto j = ccf::parse_json_safe(segments.receipt.begin(), segments.receipt.end()); @@ -322,8 +318,8 @@ namespace ccf if (prev_service_signing_keys) { - const auto key = get_previous_service_classical_signing_key( - prev_service_signing_keys, prev_service_identity); + const auto key = + get_previous_service_classical_signing_key(prev_service_signing_keys); if (!v->verify_certificate_signature(key->public_key_pem())) { throw std::logic_error( @@ -332,24 +328,10 @@ namespace ccf } LOG_DEBUG_FMT("Previous service signing key endorses snapshot signer"); } - else if (prev_service_identity) - { - ccf::crypto::Pem prev_pem(*prev_service_identity); - if (!v->verify_certificate( - {&prev_pem}, {}, true /* ignore_time */ - )) - { - throw std::logic_error( - "Previous service identity does not endorse the node identity " - "that signed the snapshot"); - } - LOG_DEBUG_FMT("Previous service identity endorses snapshot signer"); - } } static void verify_snapshot( const SnapshotSegments& segments, - std::optional> prev_service_identity = std::nullopt, const std::optional& prev_service_signing_keys = std::nullopt) { @@ -382,14 +364,12 @@ namespace ccf if (first_byte == ENCODED_COSE_SIGN1_TAG) { LOG_DEBUG_FMT("Snapshot with COSE receipt detected"); - verify_cose_snapshot_receipt( - segments, prev_service_identity, prev_service_signing_keys); + verify_cose_snapshot_receipt(segments, prev_service_signing_keys); } else if (first_byte == '{') { LOG_DEBUG_FMT("Snapshot with JSON receipt detected"); - verify_json_snapshot_receipt( - segments, prev_service_identity, prev_service_signing_keys); + verify_json_snapshot_receipt(segments, prev_service_signing_keys); } else { diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 929d052edb5c..f2f3f0e131f3 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -194,15 +194,83 @@ TEST_CASE("Legacy JSON snapshot receipts are verified with signing keys") {ccf::SigningKeyType::CLASSICAL, ccf::crypto::make_ec_key_pair()->public_key_pem()}}; - REQUIRE_NOTHROW(ccf::verify_snapshot(segments, std::nullopt, service_keys)); + REQUIRE_NOTHROW(ccf::verify_snapshot(segments, service_keys)); REQUIRE_THROWS_WITH( - ccf::verify_snapshot(segments, std::nullopt, other_keys), + ccf::verify_snapshot(segments, other_keys), "Previous service identity does not endorse the node identity that " "signed the snapshot"); + INFO("A previous service certificate resolves to its signing key"); + REQUIRE_NOTHROW(ccf::verify_snapshot( + segments, + ccf::resolve_previous_service_signing_keys( + std::nullopt, service_cert.raw()))); + INFO("Mismatching keys do not fall back to a matching certificate"); - REQUIRE_THROWS( - ccf::verify_snapshot(segments, service_cert.raw(), other_keys)); + REQUIRE_THROWS(ccf::verify_snapshot( + segments, + ccf::resolve_previous_service_signing_keys( + other_keys, service_cert.raw()))); +} + +TEST_CASE("COSE snapshot receipts are verified with signing keys") +{ + using namespace std::literals; + const auto valid_from = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 1h); + const auto valid_to = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() + 1h); + + const auto service_kp = ccf::crypto::make_ec_key_pair(); + const std::vector snapshot = {1, 2, 3}; + + // The snapshot evidence transaction is the only leaf after genesis + const ccf::crypto::Sha256Hash write_set_digest(std::string("write set")); + const std::string commit_evidence = "ce:2.4:abcd"; + ccf::crypto::Sha256Hash claims_digest(snapshot.data(), snapshot.size()); + const ccf::kv::Version evidence_seqno = 1; + ccf::MerkleTreeHistory tree; + tree.append(ccf::crypto::Sha256Hash( + write_set_digest, ccf::crypto::Sha256Hash(commit_evidence), claims_digest)); + const auto root = tree.get_root(); + + const ccf::CoseSignatureMap cose_sigs{ + {ccf::IdentityType::CLASSICAL, + ccf::cose::sign_ledger( + *service_kp, + ccf::crypto::kid_from_key(service_kp->public_key_der()), + 1700000000, + "issuer", + "subject", + "2.4", + root.h)}}; + const auto receipt_bytes = ccf::build_and_serialise_receipt( + cose_sigs, + tree.serialise(), + evidence_seqno, + write_set_digest, + commit_evidence, + std::move(claims_digest)); + REQUIRE(receipt_bytes.front() == 0xD2); + const ccf::SnapshotSegments segments{snapshot, receipt_bytes}; + + const ccf::ServiceSigningKeys service_keys{ + {ccf::SigningKeyType::CLASSICAL, service_kp->public_key_pem()}}; + const ccf::ServiceSigningKeys other_keys{ + {ccf::SigningKeyType::CLASSICAL, + ccf::crypto::make_ec_key_pair()->public_key_pem()}}; + + REQUIRE_NOTHROW(ccf::verify_snapshot(segments, service_keys)); + REQUIRE_THROWS_WITH( + ccf::verify_snapshot(segments, other_keys), + "Previous service identity does not match the service identity that " + "signed the snapshot"); + + INFO("A joining node derives the signing key from the service certificate"); + const auto service_cert = + service_kp->self_sign("CN=service", valid_from, valid_to); + REQUIRE_NOTHROW(ccf::verify_snapshot( + segments, ccf::service_signing_keys_from_certificate(service_cert.raw()))); } TEST_CASE("Recovery snapshot endorsement scan bounds candidate endorsements") From 9595388334c53d3b5367e01418e18b692df42d28 Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Tue, 6 Oct 2026 15:00:14 +0000 Subject: [PATCH 4/6] COSE key --- CHANGELOG.md | 2 +- doc/governance/accept_recovery.rst | 8 ++--- doc/host_config_schema/host_config.json | 6 ++-- include/ccf/node/configuration.h | 2 +- include/ccf/service_signing_keys.h | 10 ++++-- samples/config/recover_config.json | 4 +-- samples/config/start_config.json | 2 +- samples/constitutions/default/actions.js | 12 +++++-- samples/minimal_ccf/app/actions.js | 12 +++++-- src/crypto/cose.cpp | 36 ++++++++----------- src/crypto/cose.h | 13 +++++++ src/host/run.cpp | 6 ++-- src/node/identity.h | 45 +++++++++++++++++++---- src/node/node_state.h | 24 +++++++------ src/node/rpc/test/node_frontend_test.cpp | 6 +++- src/node/snapshot_serdes.h | 6 ++-- src/node/startup_inputs.h | 11 +++--- src/node/test/identity_types.cpp | 39 +++++++++++++++----- src/node/test/snapshotter.cpp | 20 +++++------ src/service/tables/identity_types.h | 4 ++- tests/infra/consortium.py | 14 ++++++-- tests/infra/network.py | 46 ++++++++++++++++++------ tests/infra/remote.py | 2 +- tests/recovery.py | 32 ++++++++++------- tests/start_network.py | 2 +- 25 files changed, 248 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4842e2920cb9..f9e55b25d23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- The `transition_service_to_open_with_signing_keys` proposal action opens a service with `previous_service_signing_keys` and `next_service_signing_keys`, maps of identity types to PEM public keys, instead of certificates. The public header `ccf/service_signing_keys.h` declares `ccf::ServiceSigningKeys` and `ccf::SigningKeyType`. See [accepting recovery](https://microsoft.github.io/CCF/main/governance/accept_recovery.html) (#8477). +- The `transition_service_to_open_with_signing_keys` proposal action opens a service with `previous_service_signing_keys` and `next_service_signing_keys`, base64-encoded [COSE_Key](https://www.rfc-editor.org/rfc/rfc9052#section-7) public keys, instead of certificates. Nodes write these keys to the files configured by `command.service_signing_key_files`, which recovery accepts through `command.recover.previous_service_signing_key_files` (#8477). ### Deprecated diff --git a/doc/governance/accept_recovery.rst b/doc/governance/accept_recovery.rst index a69ee943c56a..e6edf49c8a90 100644 --- a/doc/governance/accept_recovery.rst +++ b/doc/governance/accept_recovery.rst @@ -84,11 +84,11 @@ A member proposes to recover the network and other members can vote on the propo Once the proposal to recover the network has passed under the rules of the :term:`Constitution`, the recovered service is ready for members to submit their recovery shares. -Members can open the recovered service with either of two proposals. ``transition_service_to_open`` takes ``previous_service_identity`` and ``next_service_identity``, PEM certificates. ``transition_service_to_open_with_signing_keys`` takes ``previous_service_signing_keys`` and ``next_service_signing_keys``, JSON objects mapping identity types to PEM public keys, and does not accept certificates. The previous and next values must match the previous service identity recorded in the ledger and the recovered service, respectively. Each key map must contain a ``CLASSICAL`` key, and only ``CLASSICAL`` keys are compared. +Members can open the recovered service with either of two proposals. ``transition_service_to_open`` takes ``previous_service_identity`` and ``next_service_identity``, PEM certificates. ``transition_service_to_open_with_signing_keys`` takes ``previous_service_signing_keys`` and ``next_service_signing_keys``, JSON objects mapping identity types to base64-encoded `COSE_Key `_ public keys, and does not accept certificates. The previous and next values must match the previous service identity recorded in the ledger and the recovered service, respectively. Each key map must contain a ``CLASSICAL`` key, an EC2 ``COSE_Key``, and only ``CLASSICAL`` keys are compared. The ``previous_service_identity`` argument is deprecated, so recovery proposals should use ``transition_service_to_open_with_signing_keys``. These identities are recorded on the ledger with the proposal. -Each service writes its signing keys to the files configured by ``command.service_signing_key_files``, by default ``service_signing_key_classical.pem``. A ``transition_service_to_open_with_signing_keys`` proposal uses the previous service's file and the recovered service's file: +Each service writes its signing keys to the files configured by ``command.service_signing_key_files``, by default ``service_signing_key_classical.cbor``. Each file is a CBOR-encoded ``COSE_Key`` whose ``alg`` is the algorithm of the ledger's COSE signatures. A ``transition_service_to_open_with_signing_keys`` proposal uses the base64 encoding of the previous service's file and of the recovered service's file: .. code-block:: json @@ -98,10 +98,10 @@ Each service writes its signing keys to the files configured by ``command.servic "name": "transition_service_to_open_with_signing_keys", "args": { "previous_service_signing_keys": { - "CLASSICAL": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" + "CLASSICAL": "pAECAzgiIAIhWDA..." }, "next_service_signing_keys": { - "CLASSICAL": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" + "CLASSICAL": "pAECAzgiIAIhWDB..." } } } diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index d43c5f16395b..c7c0d967b604 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -172,12 +172,12 @@ }, "service_signing_key_files": { "type": "object", - "default": { "CLASSICAL": "service_signing_key_classical.pem" }, + "default": { "CLASSICAL": "service_signing_key_classical.cbor" }, "properties": { "CLASSICAL": { "type": "string", "minLength": 1, - "description": "Output path for the classical service signing public key (PEM)" + "description": "Output path for the classical service signing public key (CBOR COSE_Key)" } }, "required": ["CLASSICAL"], @@ -394,7 +394,7 @@ "CLASSICAL": { "type": "string", "minLength": 1, - "description": "Path to the previous classical service signing public key (PEM)" + "description": "Path to the previous classical service signing public key (CBOR COSE_Key)" } }, "required": ["CLASSICAL"], diff --git a/include/ccf/node/configuration.h b/include/ccf/node/configuration.h index 134ceccde47c..d7bec6d447f4 100644 --- a/include/ccf/node/configuration.h +++ b/include/ccf/node/configuration.h @@ -228,7 +228,7 @@ namespace ccf StartType type = StartType::Start; std::string service_certificate_file = "service_cert.pem"; std::map service_signing_key_files = { - {SigningKeyType::CLASSICAL, "service_signing_key_classical.pem"}}; + {SigningKeyType::CLASSICAL, "service_signing_key_classical.cbor"}}; struct Start { diff --git a/include/ccf/service_signing_keys.h b/include/ccf/service_signing_keys.h index b2342ba93dd0..950382a8dfc8 100644 --- a/include/ccf/service_signing_keys.h +++ b/include/ccf/service_signing_keys.h @@ -2,17 +2,21 @@ // Licensed under the Apache 2.0 License. #pragma once -#include "ccf/crypto/pem.h" - +#include #include #include +#include namespace ccf { + // Names of the service signing identity types. struct SigningKeyType { static constexpr auto CLASSICAL = "CLASSICAL"; + static constexpr auto PQ = "PQ"; }; - using ServiceSigningKeys = std::map; + // Service signing public keys by identity type. Each key is a CBOR-encoded + // COSE_Key (RFC 9052 Section 7), which is a base64 string in JSON. + using ServiceSigningKeys = std::map>; } diff --git a/samples/config/recover_config.json b/samples/config/recover_config.json index a291059db903..bcb1cdc8b521 100644 --- a/samples/config/recover_config.json +++ b/samples/config/recover_config.json @@ -20,14 +20,14 @@ "type": "Recover", "service_certificate_file": "service_cert.pem", "service_signing_key_files": { - "CLASSICAL": "service_signing_key_classical.pem" + "CLASSICAL": "service_signing_key_classical.cbor" }, "recover": { "initial_service_certificate_validity_days": 1, "service_cert_subject_name": "CN=A Sample CCF Service", "previous_service_identity_file": "previous_service_cert.pem", "previous_service_signing_key_files": { - "CLASSICAL": "previous_service_signing_key_classical.pem" + "CLASSICAL": "previous_service_signing_key_classical.cbor" } } }, diff --git a/samples/config/start_config.json b/samples/config/start_config.json index b7bba712c981..b56e46e72ae5 100644 --- a/samples/config/start_config.json +++ b/samples/config/start_config.json @@ -29,7 +29,7 @@ "type": "Start", "service_certificate_file": "service_cert.pem", "service_signing_key_files": { - "CLASSICAL": "service_signing_key_classical.pem" + "CLASSICAL": "service_signing_key_classical.cbor" }, "start": { "constitution_files": [ diff --git a/samples/constitutions/default/actions.js b/samples/constitutions/default/actions.js index 4c80785cd183..5cf0f3fc8638 100644 --- a/samples/constitutions/default/actions.js +++ b/samples/constitutions/default/actions.js @@ -419,9 +419,17 @@ function checkServiceSigningKeys(value, field) { throw new Error(`${field} must be an object`); } checkType(value, "object", field); - checkType(value.CLASSICAL, "string", `${field}.CLASSICAL (PEM public key)`); + checkType( + value.CLASSICAL, + "string", + `${field}.CLASSICAL (base64-encoded COSE_Key)`, + ); for (const [identityType, key] of Object.entries(value)) { - checkType(key, "string", `${field}.${identityType} (PEM public key)`); + checkType( + key, + "string", + `${field}.${identityType} (base64-encoded COSE_Key)`, + ); } } diff --git a/samples/minimal_ccf/app/actions.js b/samples/minimal_ccf/app/actions.js index e2688a3e619e..808fa8f88505 100644 --- a/samples/minimal_ccf/app/actions.js +++ b/samples/minimal_ccf/app/actions.js @@ -399,9 +399,17 @@ function checkServiceSigningKeys(value, field) { throw new Error(`${field} must be an object`); } checkType(value, "object", field); - checkType(value.CLASSICAL, "string", `${field}.CLASSICAL (PEM public key)`); + checkType( + value.CLASSICAL, + "string", + `${field}.CLASSICAL (base64-encoded COSE_Key)`, + ); for (const [identityType, key] of Object.entries(value)) { - checkType(key, "string", `${field}.${identityType} (PEM public key)`); + checkType( + key, + "string", + `${field}.${identityType} (base64-encoded COSE_Key)`, + ); } } diff --git a/src/crypto/cose.cpp b/src/crypto/cose.cpp index cffc86670e62..993cf3afc6d5 100644 --- a/src/crypto/cose.cpp +++ b/src/crypto/cose.cpp @@ -70,29 +70,23 @@ namespace ccf::cose return make_cose_sign1_envelope( protected_bytes, payload, signature, detached); } + } - struct SigningAlgorithm - { - int64_t alg; - crypto::MDType md; - }; - - SigningAlgorithm algorithm_for_curve(crypto::CurveID curve) + SigningAlgorithm algorithm_for_curve(crypto::CurveID curve) + { + switch (curve) { - switch (curve) - { - case crypto::CurveID::SECP256R1: - return {alg::ES256, crypto::MDType::SHA256}; - case crypto::CurveID::SECP384R1: - return {alg::ES384, crypto::MDType::SHA384}; - case crypto::CurveID::SECP521R1: - return {alg::ES512, crypto::MDType::SHA512}; - case crypto::CurveID::NONE: - case crypto::CurveID::CURVE25519: - case crypto::CurveID::X25519: - default: - throw std::runtime_error("Unsupported COSE signing curve"); - } + case crypto::CurveID::SECP256R1: + return {alg::ES256, crypto::MDType::SHA256}; + case crypto::CurveID::SECP384R1: + return {alg::ES384, crypto::MDType::SHA384}; + case crypto::CurveID::SECP521R1: + return {alg::ES512, crypto::MDType::SHA512}; + case crypto::CurveID::NONE: + case crypto::CurveID::CURVE25519: + case crypto::CurveID::X25519: + default: + throw std::runtime_error("Unsupported COSE signing curve"); } } diff --git a/src/crypto/cose.h b/src/crypto/cose.h index c58b6a8a024d..d33aa37579d1 100644 --- a/src/crypto/cose.h +++ b/src/crypto/cose.h @@ -3,6 +3,9 @@ #pragma once +#include "ccf/crypto/curve.h" +#include "ccf/crypto/md_type.h" + #include #include #include @@ -18,6 +21,16 @@ namespace ccf { namespace cose { + /// COSE algorithm, and its digest, of ECDSA signatures by a key on a curve + struct SigningAlgorithm + { + int64_t alg; + crypto::MDType md; + }; + + /// @throws std::runtime_error if curve is not P-256, P-384 or P-521 + SigningAlgorithm algorithm_for_curve(crypto::CurveID curve); + std::vector make_cose_sign1_tbs( std::span protected_header, std::span payload); diff --git a/src/host/run.cpp b/src/host/run.cpp index 23ad548a9eb3..4c8895069978 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -317,9 +317,11 @@ namespace ccf { const auto& path = config.command.service_signing_key_files.at(identity_type); - files::dump(public_key.raw(), path); + files::dump(public_key, path); LOG_INFO_FMT( - "Output {} service signing public key to {}", identity_type, path); + "Output {} service signing public key (COSE_Key) to {}", + identity_type, + path); } } } diff --git a/src/node/identity.h b/src/node/identity.h index e6a04c092e0b..72370d54ada3 100644 --- a/src/node/identity.h +++ b/src/node/identity.h @@ -3,10 +3,12 @@ #pragma once #include "ccf/cose_signatures_config.h" +#include "ccf/crypto/cose_key.h" #include "ccf/crypto/curve.h" #include "ccf/crypto/verifier.h" #include "ccf/service_signing_keys.h" #include "crypto/certs.h" +#include "crypto/cose.h" #include "crypto/openssl/ec_key_pair.h" #include @@ -18,12 +20,40 @@ namespace ccf { + // The CLASSICAL service signing key as a COSE_Key, whose alg is the + // algorithm of the ledger's COSE signatures with that key + inline std::vector classical_signing_key_cbor( + const ccf::crypto::ECPublicKeyPtr& key) + { + return ccf::crypto::COSEKey(key).to_cbor( + ccf::cose::algorithm_for_curve(key->get_curve_id()).alg); + } + + // Parses a CLASSICAL service signing key, which may be untrusted + inline ccf::crypto::COSEKey parse_classical_signing_key( + const std::vector& cose_key) + { + auto key = ccf::crypto::COSEKey::from_cbor(cose_key); + if (key.kty() != ccf::crypto::COSEKeyType::EC2) + { + throw std::invalid_argument(fmt::format( + "{} service signing key is not an EC2 COSE_Key", + SigningKeyType::CLASSICAL)); + } + return key; + } + + inline ServiceSigningKeys service_signing_keys_from_public_key( + const ccf::crypto::ECPublicKeyPtr& key) + { + return {{SigningKeyType::CLASSICAL, classical_signing_key_cbor(key)}}; + } + inline ServiceSigningKeys service_signing_keys_from_certificate( const std::vector& certificate) { - return { - {SigningKeyType::CLASSICAL, - ccf::crypto::make_unique_verifier(certificate)->public_key_pem()}}; + return service_signing_keys_from_public_key(ccf::crypto::make_ec_public_key( + ccf::crypto::make_unique_verifier(certificate)->public_key_der())); } // Signing keys take precedence over the deprecated previous service @@ -44,23 +74,24 @@ namespace ccf return std::nullopt; } - inline ccf::crypto::ECPublicKeyPtr get_previous_service_classical_signing_key( + inline ccf::crypto::COSEKey get_previous_service_classical_signing_key( const std::optional& keys) { if (!keys.has_value()) { throw std::logic_error("No previous service identity is configured"); } - if (!keys->contains(SigningKeyType::CLASSICAL)) + const auto key = keys->find(SigningKeyType::CLASSICAL); + if (key == keys->end()) { throw std::logic_error(fmt::format( "Missing {} previous service signing public key", SigningKeyType::CLASSICAL)); } - return ccf::crypto::make_ec_public_key(keys->at(SigningKeyType::CLASSICAL)); + return parse_classical_signing_key(key->second); } - inline ccf::crypto::ECPublicKeyPtr get_previous_service_classical_signing_key( + inline ccf::crypto::COSEKey get_previous_service_classical_signing_key( const std::optional& keys, const std::optional>& certificate) { diff --git a/src/node/node_state.h b/src/node/node_state.h index 5b192df9e629..d91f81fbc403 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -567,10 +567,10 @@ namespace ccf void verify_recovery_snapshot_candidate_unsafe( const SnapshotSegments& segments, ccf::kv::Version snapshot_seqno) { - const auto target_key = get_previous_service_classical_signing_key( - startup_inputs.previous_service_signing_keys, - startup_inputs.previous_service_identity) - ->public_key_der(); + const auto previous_key = get_previous_service_classical_signing_key( + startup_inputs.previous_service_signing_keys, + startup_inputs.previous_service_identity); + const auto target_key = previous_key.ec_public_key()->public_key_der(); verify_snapshot_seqno( segments, network.tables->get_encryptor(), snapshot_seqno); @@ -603,7 +603,7 @@ namespace ccf try { const auto verifier = - ccf::crypto::make_cose_verifier_from_key(target_key); + ccf::crypto::make_cose_verifier_from_key(previous_key); if (verifier->verify_detached(segments.receipt, receipt.merkle_root)) { LOG_INFO_FMT( @@ -1372,9 +1372,9 @@ namespace ccf } } - ServiceSigningKeys service_signing_keys{ - {SigningKeyType::CLASSICAL, - network.identity->get_key_pair()->public_key_pem()}}; + const auto service_signing_keys = + service_signing_keys_from_public_key(ccf::crypto::make_ec_public_key( + network.identity->get_key_pair()->public_key_der())); return { new_self_signed_node_cert, network.identity->cert, @@ -2799,7 +2799,7 @@ namespace ccf } static std::vector classical_signing_key_der( - const ServiceSigningKeys& keys, const char* name) + const ServiceSigningKeys& keys, std::string_view role) { const auto key = keys.find(SigningKeyType::CLASSICAL); if (key == keys.end()) @@ -2807,9 +2807,11 @@ namespace ccf throw std::logic_error(fmt::format( "Missing {} {} service signing key", SigningKeyType::CLASSICAL, - name)); + role)); } - return ccf::crypto::make_ec_public_key(key->second)->public_key_der(); + return parse_classical_signing_key(key->second) + .ec_public_key() + ->public_key_der(); } // Checks the identities in a transition_service_to_open_with_signing_keys diff --git a/src/node/rpc/test/node_frontend_test.cpp b/src/node/rpc/test/node_frontend_test.cpp index 3bc0a2634c92..63e29d9dc041 100644 --- a/src/node/rpc/test/node_frontend_test.cpp +++ b/src/node/rpc/test/node_frontend_test.cpp @@ -416,7 +416,11 @@ TEST_CASE("Startup inputs are resolved by start type") keys_only.command.recover.previous_service_signing_key_files = std::map{ {SigningKeyType::CLASSICAL, - write_test_file(dir, "previous_key.pem", kp->public_key_pem().str())}}; + write_test_file(dir, "previous_key.cbor", [&]() { + const auto cose_key = classical_signing_key_cbor( + ccf::crypto::make_ec_public_key(kp->public_key_der())); + return std::string(cose_key.begin(), cose_key.end()); + }())}}; keys_only.command.recover.service_cert_subject_name = "CN=Recovered Service"; const auto inputs = resolve(keys_only, StartType::Recover); diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 19f0e3eab343..8ce81f697ab9 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -264,8 +264,7 @@ namespace ccf { const auto key = get_previous_service_classical_signing_key(prev_service_signing_keys); - auto verifier = - ccf::crypto::make_cose_verifier_from_key(key->public_key_der()); + auto verifier = ccf::crypto::make_cose_verifier_from_key(key); if (!verifier->verify_detached(segments.receipt, receipt.merkle_root)) { throw std::logic_error( @@ -320,7 +319,8 @@ namespace ccf { const auto key = get_previous_service_classical_signing_key(prev_service_signing_keys); - if (!v->verify_certificate_signature(key->public_key_pem())) + if (!v->verify_certificate_signature( + key.ec_public_key()->public_key_pem())) { throw std::logic_error( "Previous service identity does not endorse the node identity " diff --git a/src/node/startup_inputs.h b/src/node/startup_inputs.h index e22bf62b7c5f..2086b9415380 100644 --- a/src/node/startup_inputs.h +++ b/src/node/startup_inputs.h @@ -220,13 +220,12 @@ namespace ccf { auto& keys = inputs.previous_service_signing_keys.emplace(); const auto& path = key_files->at(SigningKeyType::CLASSICAL); - LOG_INFO_FMT( - "Reading previous CLASSICAL service signing public key from {}", - path); + const auto description = fmt::format( + "previous {} service signing public key", + SigningKeyType::CLASSICAL); + LOG_INFO_FMT("Reading {} from {}", description, path); keys.emplace( - SigningKeyType::CLASSICAL, - ccf::crypto::Pem(read_startup_file( - path, "previous CLASSICAL service signing public key"))); + SigningKeyType::CLASSICAL, read_startup_file(path, description)); } break; } diff --git a/src/node/test/identity_types.cpp b/src/node/test/identity_types.cpp index 0bf9bbaa3c03..ab107d712c08 100644 --- a/src/node/test/identity_types.cpp +++ b/src/node/test/identity_types.cpp @@ -3,10 +3,12 @@ #include "service/tables/identity_types.h" +#include "ccf/crypto/rsa_key_pair.h" #include "ccf/kv/unit.h" #include "ccf/node/configuration.h" #include "ccf/service_signing_keys.h" #include "crypto/openssl/ec_key_pair.h" +#include "node/identity.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include @@ -103,15 +105,15 @@ TEST_CASE("Service signing key files are a JSON object keyed by identity name") const nlohmann::json paths = command.service_signing_key_files; REQUIRE( paths == - nlohmann::json{{"CLASSICAL", "service_signing_key_classical.pem"}}); + nlohmann::json{{"CLASSICAL", "service_signing_key_classical.cbor"}}); const nlohmann::json custom = { {"type", "Start"}, - {"service_signing_key_files", {{"CLASSICAL", "custom_signing_key.pem"}}}}; + {"service_signing_key_files", {{"CLASSICAL", "custom_signing_key.cbor"}}}}; const auto parsed = custom.get(); REQUIRE( parsed.service_signing_key_files.at(ccf::SigningKeyType::CLASSICAL) == - "custom_signing_key.pem"); + "custom_signing_key.cbor"); const nlohmann::json round_trip = parsed; REQUIRE( round_trip.at("service_signing_key_files") == @@ -119,15 +121,36 @@ TEST_CASE("Service signing key files are a JSON object keyed by identity name") } TEST_CASE( - "Service signing public keys round-trip as PEM strings in a JSON object") + "Service signing public keys round-trip as base64 COSE_Keys in a JSON " + "object") { const ccf::crypto::ECKeyPair_OpenSSL key_pair( ccf::crypto::CurveID::SECP384R1); - const auto public_key = key_pair.public_key_pem(); - const ccf::ServiceSigningKeys keys{ - {ccf::SigningKeyType::CLASSICAL, public_key}}; + const auto public_key = + ccf::crypto::make_ec_public_key(key_pair.public_key_der()); + const auto keys = ccf::service_signing_keys_from_public_key(public_key); + + const auto& cose_key = keys.at(ccf::SigningKeyType::CLASSICAL); + const auto parsed = ccf::crypto::COSEKey::from_cbor(cose_key); + REQUIRE(parsed.alg() == ccf::cose::alg::ES384); + REQUIRE( + parsed.ec_public_key()->public_key_der() == public_key->public_key_der()); + REQUIRE( + ccf::parse_classical_signing_key(cose_key) + .ec_public_key() + ->public_key_der() == public_key->public_key_der()); const nlohmann::json j = keys; - REQUIRE(j == nlohmann::json{{"CLASSICAL", public_key.str()}}); + REQUIRE( + j == nlohmann::json{{"CLASSICAL", ccf::crypto::b64_from_raw(cose_key)}}); REQUIRE(j.get() == keys); + + INFO("Only EC2 COSE_Keys are CLASSICAL signing keys"); + REQUIRE_THROWS_AS( + ccf::parse_classical_signing_key( + ccf::crypto::COSEKey(ccf::crypto::make_rsa_key_pair()).to_cbor(-37)), + std::invalid_argument); + REQUIRE_THROWS_AS( + ccf::parse_classical_signing_key({'n', 'o', 't', ' ', 'c', 'b', 'o', 'r'}), + std::invalid_argument); } diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index f2f3f0e131f3..a6cecbc95975 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -28,6 +28,12 @@ auto node_kp = ccf::crypto::make_ec_key_pair(); +ccf::ServiceSigningKeys signing_keys_of(const ccf::crypto::ECKeyPairPtr& kp) +{ + return ccf::service_signing_keys_from_public_key( + ccf::crypto::make_ec_public_key(kp->public_key_der())); +} + using StringString = ccf::kv::Map; namespace fs = std::filesystem; @@ -188,11 +194,8 @@ TEST_CASE("Legacy JSON snapshot receipts are verified with signing keys") receipt_str.begin(), receipt_str.end()); const ccf::SnapshotSegments segments{snapshot, receipt_bytes}; - const ccf::ServiceSigningKeys service_keys{ - {ccf::SigningKeyType::CLASSICAL, service_kp->public_key_pem()}}; - const ccf::ServiceSigningKeys other_keys{ - {ccf::SigningKeyType::CLASSICAL, - ccf::crypto::make_ec_key_pair()->public_key_pem()}}; + const auto service_keys = signing_keys_of(service_kp); + const auto other_keys = signing_keys_of(ccf::crypto::make_ec_key_pair()); REQUIRE_NOTHROW(ccf::verify_snapshot(segments, service_keys)); REQUIRE_THROWS_WITH( @@ -254,11 +257,8 @@ TEST_CASE("COSE snapshot receipts are verified with signing keys") REQUIRE(receipt_bytes.front() == 0xD2); const ccf::SnapshotSegments segments{snapshot, receipt_bytes}; - const ccf::ServiceSigningKeys service_keys{ - {ccf::SigningKeyType::CLASSICAL, service_kp->public_key_pem()}}; - const ccf::ServiceSigningKeys other_keys{ - {ccf::SigningKeyType::CLASSICAL, - ccf::crypto::make_ec_key_pair()->public_key_pem()}}; + const auto service_keys = signing_keys_of(service_kp); + const auto other_keys = signing_keys_of(ccf::crypto::make_ec_key_pair()); REQUIRE_NOTHROW(ccf::verify_snapshot(segments, service_keys)); REQUIRE_THROWS_WITH( diff --git a/src/service/tables/identity_types.h b/src/service/tables/identity_types.h index 85f399d644fe..5882fefe16c3 100644 --- a/src/service/tables/identity_types.h +++ b/src/service/tables/identity_types.h @@ -4,6 +4,7 @@ #include "ccf/ds/json.h" #include "ccf/kv/serialisers/blit_serialiser.h" +#include "ccf/service_signing_keys.h" #include #include @@ -25,7 +26,8 @@ namespace ccf DECLARE_JSON_ENUM( IdentityType, - {{IdentityType::CLASSICAL, "CLASSICAL"}, {IdentityType::PQ, "PQ"}}); + {{IdentityType::CLASSICAL, SigningKeyType::CLASSICAL}, + {IdentityType::PQ, SigningKeyType::PQ}}); /// Encoding of the certificate or public key held by an identity. enum class IdentityKind : uint8_t diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index 17ff79e35693..88b89ad44140 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the Apache 2.0 License. +import base64 import datetime import glob import http @@ -29,6 +30,15 @@ def slurp_file(path): return open(path, encoding="utf-8").read() +def slurp_bytes(path): + with open(path, "rb") as f: + return f.read() + + +def slurp_b64(path): + return base64.b64encode(slurp_bytes(path)).decode("ascii") + + def slurp_json(path): return json.load(open(path, encoding="utf-8")) @@ -499,8 +509,8 @@ def get_service_identity(self): def get_service_signing_keys(self): return { - "CLASSICAL": slurp_file( - os.path.join(self.common_dir, "service_signing_key_classical.pem") + "CLASSICAL": slurp_b64( + os.path.join(self.common_dir, "service_signing_key_classical.cbor") ) } diff --git a/tests/infra/network.py b/tests/infra/network.py index f9bf5cdfd2db..2fcff66c928a 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -17,10 +17,10 @@ from enum import Enum, IntEnum, auto from typing import ClassVar +import cbor2 import ccf.ledger from ccf.tx_id import TxID from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from cryptography.x509 import load_pem_x509_certificate from loguru import logger as LOG @@ -34,7 +34,7 @@ import infra.proc import infra.remote from infra.clients import CCFConnectionException, CCFIOException, flush_info -from infra.consortium import slurp_file +from infra.consortium import slurp_b64, slurp_bytes, slurp_file from infra.node import CCFVersion from infra.tx_status import TxStatus @@ -56,7 +56,7 @@ def get_previous_service_identity(args): ), "previous_service_signing_keys": ( { - identity_type: slurp_file(path) + identity_type: slurp_b64(path) for identity_type, path in key_files.items() } if key_files is not None @@ -65,12 +65,35 @@ def get_previous_service_identity(args): } +# COSE curve, signature algorithm and coordinate size by curve name (RFC 9053) +COSE_EC_CURVES = { + "secp256r1": (1, -7, 32), + "secp384r1": (2, -35, 48), + "secp521r1": (3, -36, 66), +} + + +def cose_key_from_ec_public_key(public_key): + """CBOR-encoded COSE_Key (RFC 9052 Section 7) of an EC public key""" + crv, alg, size = COSE_EC_CURVES[public_key.curve.name] + numbers = public_key.public_numbers() + return cbor2.dumps( + { + 1: 2, # kty: EC2 + 3: alg, + -1: crv, + -2: numbers.x.to_bytes(size, "big"), + -3: numbers.y.to_bytes(size, "big"), + }, + canonical=True, + ) + + def service_signing_key_from_certificate(certificate): - return ( - load_pem_x509_certificate(certificate.encode("ascii"), default_backend()) - .public_key() - .public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) - .decode("ascii") + return cose_key_from_ec_public_key( + load_pem_x509_certificate( + certificate.encode("ascii"), default_backend() + ).public_key() ) @@ -84,14 +107,15 @@ def save_service_signing_keys(certificate_file, directory, key_files=None): } else: keys = { - identity_type: slurp_file(path) for identity_type, path in key_files.items() + identity_type: slurp_bytes(path) + for identity_type, path in key_files.items() } stem = os.path.splitext(os.path.basename(certificate_file))[0] paths = {} for identity_type, key in keys.items(): - path = os.path.join(directory, f"{stem}_{identity_type}_pubk.pem") - with open(path, "w", encoding="utf-8") as key_file: + path = os.path.join(directory, f"{stem}_{identity_type}_pubk.cbor") + with open(path, "wb") as key_file: key_file.write(key) paths[identity_type] = path return paths diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 80ba40f2c555..2c8021ca7c89 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -873,7 +873,7 @@ def get_startup_files(self, dst_path, timeout=FILE_TIMEOUT_S): self.get_service_signing_key_files(dst_path, timeout=timeout) def get_service_signing_key_files(self, dst_path, timeout=FILE_TIMEOUT_S): - self.remote.get("service_signing_key_classical.pem", dst_path, timeout=timeout) + self.remote.get("service_signing_key_classical.cbor", dst_path, timeout=timeout) def debug_node_cmd(self): return self.remote.debug_node_cmd() diff --git a/tests/recovery.py b/tests/recovery.py index 44c163919187..3a5c9a8629a0 100644 --- a/tests/recovery.py +++ b/tests/recovery.py @@ -2267,18 +2267,26 @@ def run_recovery_with_signing_keys_only(args): except infra.proposal.ProposalNotCreated as e: assert e.response.status_code == http.HTTPStatus.BAD_REQUEST, e.response - body, ballot = consortium.make_proposal( - action, next_service_signing_keys={"CLASSICAL": "not a PEM key"} - ) - proposal = member.propose(primary, body) - try: - consortium.vote_using_majority(primary, proposal, ballot) - assert False, "Proposal with a non-PEM key should not be accepted" - except infra.proposal.ProposalNotAccepted as e: - assert ( - "PEM constructed with non-PEM data" - in e.response.body.json()["error"]["message"] - ), e.response + for malformed_key, expected_error in ( + ("not base64!", "is not valid base64"), + ( + base64.b64encode(b"not a COSE_Key").decode("ascii"), + "Invalid COSE_Key", + ), + ): + body, ballot = consortium.make_proposal( + action, next_service_signing_keys={"CLASSICAL": malformed_key} + ) + proposal = member.propose(primary, body) + try: + consortium.vote_using_majority(primary, proposal, ballot) + assert ( + False + ), f"Proposal with key {malformed_key} should not be accepted" + except infra.proposal.ProposalNotAccepted as e: + assert ( + expected_error in e.response.body.json()["error"]["message"] + ), e.response body, ballot = consortium.make_proposal( action, next_service_signing_keys=next_keys diff --git a/tests/start_network.py b/tests/start_network.py index 8a1ea147c2b4..b2fde8562b0f 100644 --- a/tests/start_network.py +++ b/tests/start_network.py @@ -129,7 +129,7 @@ def run(args): shutil.copy(previous_service_cert, backup_location) args.previous_service_identity_file = backup_location signing_key_file = os.path.join( - args.common_dir, "service_signing_key_classical.pem" + args.common_dir, "service_signing_key_classical.cbor" ) args.previous_service_signing_key_files = ( infra.network.save_service_signing_keys( From bb50921287b3f3b23a5a46464d412ccef4ce76a3 Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Wed, 7 Oct 2026 10:17:35 +0000 Subject: [PATCH 5/6] Fix LTS and comments --- include/ccf/service_signing_keys.h | 7 ++++--- src/node/node_state.h | 2 +- tests/infra/consortium.py | 3 ++- tests/infra/network.py | 2 +- tests/infra/remote.py | 15 ++++++++++++++- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/include/ccf/service_signing_keys.h b/include/ccf/service_signing_keys.h index 950382a8dfc8..ce46457d9872 100644 --- a/include/ccf/service_signing_keys.h +++ b/include/ccf/service_signing_keys.h @@ -9,14 +9,15 @@ namespace ccf { - // Names of the service signing identity types. + /// Names of the service signing identity types. struct SigningKeyType { static constexpr auto CLASSICAL = "CLASSICAL"; static constexpr auto PQ = "PQ"; }; - // Service signing public keys by identity type. Each key is a CBOR-encoded - // COSE_Key (RFC 9052 Section 7), which is a base64 string in JSON. + /// Service signing public keys by identity type. Each key is a CBOR-encoded + /// COSE_Key (RFC 9052 Section 7). In JSON, such as in proposals, each key is + /// a base64 string (RFC 4648 Section 4, with padding). using ServiceSigningKeys = std::map>; } diff --git a/src/node/node_state.h b/src/node/node_state.h index d91f81fbc403..22ff8adc0172 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -1372,7 +1372,7 @@ namespace ccf } } - const auto service_signing_keys = + auto service_signing_keys = service_signing_keys_from_public_key(ccf::crypto::make_ec_public_key( network.identity->get_key_pair()->public_key_der())); return { diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index 88b89ad44140..9f96c32c5df6 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -22,6 +22,7 @@ import infra.network import infra.node import infra.proc +import infra.remote from infra.node import CCFVersion from infra.proposal import ProposalState @@ -757,7 +758,7 @@ def transition_service_to_open( args = {} if CCFVersion(remote_node.version) > CCFVersion("ccf-2.0.0-rc3"): if ( - CCFVersion(remote_node.version) > CCFVersion("ccf-7.0.18") + infra.remote.supports_service_signing_keys(remote_node.version) and previous_service_signing_keys is not None ): action = "transition_service_to_open_with_signing_keys" diff --git a/tests/infra/network.py b/tests/infra/network.py index 2fcff66c928a..4185860ad80b 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -2631,7 +2631,7 @@ def save_service_identity(self, args): path, identity = self.save_service_identity_to_file() args.previous_service_identity_file = path signing_key_file = os.path.join( - self.common_dir, "service_signing_key_classical.pem" + self.common_dir, "service_signing_key_classical.cbor" ) args.previous_service_signing_key_files = save_service_signing_keys( path, diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 2c8021ca7c89..08e3812b9c87 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -32,6 +32,19 @@ DEFAULT_PERF_RECORD_ARGS = "-m 16 -e task-clock:u -F 99 -g --call-graph dwarf --quiet" +def supports_service_signing_keys(version): + """ + Whether nodes of this version write service signing key files, accept + command.recover.previous_service_signing_key_files, and open with + transition_service_to_open_with_signing_keys. These were introduced after + 7.0.18; development builds describe themselves as post-releases of the + last tag, so this compares against that release rather than the next. + """ + if version is None: + return True + return ccf._versionifier.to_python_version(version) > Version("7.0.18") + + class CmdMixin: perfable = True @@ -552,7 +565,7 @@ def __init__( self.name = f"{label}_{local_node_id}" self.start_type = start_type - self.supports_service_signing_keys = version is None + self.supports_service_signing_keys = supports_service_signing_keys(version) self.local_node_id = local_node_id self.pem = f"{local_node_id}.pem" self.node_address_file = f"{local_node_id}.node_address" From 5165512edd9e9c74564888f5560bb1487da36f59 Mon Sep 17 00:00:00 2001 From: Max Tropets Date: Wed, 7 Oct 2026 12:19:03 +0000 Subject: [PATCH 6/6] Get keys from the endpoint --- CHANGELOG.md | 2 +- doc/schemas/node_openapi.json | 42 ++++++++++++++++++++++++++++- src/node/rpc/node_frontend.h | 50 ++++++++++++++++++++++++++++++++++- tests/infra/network.py | 50 ++++++++++++++++++++++------------- tests/recovery.py | 7 +++++ tests/start_network.py | 7 ++++- 6 files changed, 135 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e55b25d23d..5ecfb1a21a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- The `transition_service_to_open_with_signing_keys` proposal action opens a service with `previous_service_signing_keys` and `next_service_signing_keys`, base64-encoded [COSE_Key](https://www.rfc-editor.org/rfc/rfc9052#section-7) public keys, instead of certificates. Nodes write these keys to the files configured by `command.service_signing_key_files`, which recovery accepts through `command.recover.previous_service_signing_key_files` (#8477). +- The `transition_service_to_open_with_signing_keys` proposal action opens a service with `previous_service_signing_keys` and `next_service_signing_keys`, base64-encoded [COSE_Key](https://www.rfc-editor.org/rfc/rfc9052#section-7) public keys, instead of certificates. Nodes write these keys to the files configured by `command.service_signing_key_files`, which recovery accepts through `command.recover.previous_service_signing_key_files`, and return them from `GET /node/service/signing_keys` (#8477). ### Deprecated diff --git a/doc/schemas/node_openapi.json b/doc/schemas/node_openapi.json index edb604e822e9..eb5af57b41a5 100644 --- a/doc/schemas/node_openapi.json +++ b/doc/schemas/node_openapi.json @@ -402,6 +402,17 @@ ], "type": "object" }, + "GetServiceSigningKeys__Out": { + "properties": { + "service_signing_keys": { + "$ref": "#/components/schemas/string_to_base64string" + } + }, + "required": [ + "service_signing_keys" + ], + "type": "object" + }, "GetState__Out": { "properties": { "last_recovered_seqno": { @@ -941,6 +952,12 @@ }, "type": "object" }, + "string_to_base64string": { + "additionalProperties": { + "$ref": "#/components/schemas/base64string" + }, + "type": "object" + }, "uint32": { "maximum": 4294967295, "minimum": 0, @@ -970,7 +987,7 @@ "info": { "description": "This API provides public, uncredentialed access to service and node state.", "title": "CCF Public Node API", - "version": "5.0.8" + "version": "5.0.9" }, "openapi": "3.0.0", "paths": { @@ -1868,6 +1885,29 @@ } } }, + "/node/service/signing_keys": { + "get": { + "operationId": "GetNodeServiceSigningKeys", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetServiceSigningKeys__Out" + } + } + }, + "description": "Default response description" + }, + "default": { + "$ref": "#/components/responses/default" + } + }, + "x-ccf-forwarding": { + "$ref": "#/components/x-ccf-forwarding/sometimes" + } + } + }, "/node/snapshot": { "get": { "operationId": "GetNodeSnapshot", diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index ba4204682712..6b011c65a2e3 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -22,6 +22,7 @@ #include "ds/std_formatters.h" #include "frontend.h" #include "node/cose_common.h" +#include "node/identity.h" #include "node/internal_tables_access.h" #include "node/network_state.h" #include "node/rpc/file_serving_handlers.h" @@ -35,6 +36,7 @@ #include "node_interface.h" #include "service/tables/local_sealing.h" #include "service/tables/previous_service_identity.h" +#include "service/tables/signing_identities.h" #include "service/tables/snapshot_status.h" #include "snapshots/filenames.h" @@ -192,6 +194,18 @@ namespace ccf DECLARE_JSON_REQUIRED_FIELDS( GetServicePreviousIdentity::Out, previous_service_identity); + struct GetServiceSigningKeys + { + struct Out + { + ServiceSigningKeys service_signing_keys; + }; + }; + + DECLARE_JSON_TYPE(GetServiceSigningKeys::Out); + DECLARE_JSON_REQUIRED_FIELDS( + GetServiceSigningKeys::Out, service_signing_keys); + class NodeEndpoints : public CommonEndpointRegistry { public: @@ -1007,6 +1021,26 @@ namespace ccf "This service is not a recovery of a previous service."); } + static auto service_signing_keys( + ccf::endpoints::ReadOnlyEndpointContext& args, + nlohmann::json&& /*params*/) + { + const auto identity = + get_service_signing_identity(args.tx, IdentityType::CLASSICAL); + if (!identity.has_value()) + { + return make_error( + HTTP_STATUS_NOT_FOUND, + ccf::errors::ResourceNotFound, + "Service signing keys not available."); + } + + GetServiceSigningKeys::Out out; + out.service_signing_keys = service_signing_keys_from_public_key( + ccf::crypto::make_ec_public_key(identity->value)); + return make_success(out); + } + auto get_nodes( ccf::endpoints::ReadOnlyEndpointContext& args, nlohmann::json&& /*params*/) @@ -1842,7 +1876,7 @@ namespace ccf openapi_info.description = "This API provides public, uncredentialed access to service and node " "state."; - openapi_info.document_version = "5.0.8"; + openapi_info.document_version = "5.0.9"; } void init_handlers() override @@ -1974,6 +2008,20 @@ namespace ccf .set_auto_schema() .install(); + auto service_signing_keys = + []( + ccf::endpoints::ReadOnlyEndpointContext& args, + nlohmann::json&& json) { + return NodeEndpoints::service_signing_keys(args, std::move(json)); + }; + make_read_only_endpoint( + "/service/signing_keys", + HTTP_GET, + json_read_only_adapter(service_signing_keys), + no_auth_required) + .set_auto_schema() + .install(); + auto get_nodes = [this]( ccf::endpoints::ReadOnlyEndpointContext& args, nlohmann::json&& json) { diff --git a/tests/infra/network.py b/tests/infra/network.py index 4185860ad80b..835044dc91e4 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -34,7 +34,7 @@ import infra.proc import infra.remote from infra.clients import CCFConnectionException, CCFIOException, flush_info -from infra.consortium import slurp_b64, slurp_bytes, slurp_file +from infra.consortium import slurp_b64, slurp_file from infra.node import CCFVersion from infra.tx_status import TxStatus @@ -97,19 +97,19 @@ def service_signing_key_from_certificate(certificate): ) -def save_service_signing_keys(certificate_file, directory, key_files=None): - if key_files is None: - # Historical services did not export signing public keys separately. +def save_service_signing_keys(certificate_file, directory, keys=None): + """ + Writes the CBOR COSE_Key of each service signing key next to the service + certificate file, and returns their paths by identity type. Keys are derived + from the certificate when none are given, as for services run by releases + which do not expose them. + """ + if keys is None: keys = { "CLASSICAL": service_signing_key_from_certificate( slurp_file(certificate_file) ) } - else: - keys = { - identity_type: slurp_bytes(path) - for identity_type, path in key_files.items() - } stem = os.path.splitext(os.path.basename(certificate_file))[0] paths = {} @@ -2627,20 +2627,32 @@ def save_service_identity_to_file(self): f.write(current_ident) return previous_identity_file, current_ident + def get_service_signing_keys(self): + """ + The service signing keys as CBOR COSE_Keys by identity type, from + GET /node/service/signing_keys. Releases without it have a single + CLASSICAL key, which is that of the service certificate. + """ + n = self.find_random_node() + if not infra.remote.supports_service_signing_keys(n.version): + return { + "CLASSICAL": service_signing_key_from_certificate( + self.get_service_identity() + ) + } + with n.client() as c: + r = c.get("/node/service/signing_keys") + assert r.status_code == 200, r + return { + identity_type: base64.b64decode(key) + for identity_type, key in r.body.json()["service_signing_keys"].items() + } + def save_service_identity(self, args): path, identity = self.save_service_identity_to_file() args.previous_service_identity_file = path - signing_key_file = os.path.join( - self.common_dir, "service_signing_key_classical.cbor" - ) args.previous_service_signing_key_files = save_service_signing_keys( - path, - self.common_dir, - ( - {"CLASSICAL": signing_key_file} - if os.path.exists(signing_key_file) - else None - ), + path, self.common_dir, self.get_service_signing_keys() ) return identity diff --git a/tests/recovery.py b/tests/recovery.py index 3a5c9a8629a0..0aed210dd471 100644 --- a/tests/recovery.py +++ b/tests/recovery.py @@ -2245,6 +2245,13 @@ def run_recovery_with_signing_keys_only(args): member = consortium.get_any_active_member() action = "transition_service_to_open_with_signing_keys" next_keys = consortium.get_service_signing_keys() + assert { + identity_type: base64.b64decode(key) + for identity_type, key in next_keys.items() + } == network.get_service_signing_keys(), ( + "Service signing key files written by the node do not match " + "GET /node/service/signing_keys" + ) for malformed_keys in ( None, diff --git a/tests/start_network.py b/tests/start_network.py index b2fde8562b0f..b628b7914c9b 100644 --- a/tests/start_network.py +++ b/tests/start_network.py @@ -8,6 +8,7 @@ import sys import time +import infra.consortium import infra.e2e_args import infra.interfaces import infra.network @@ -136,7 +137,11 @@ def run(args): backup_location, args.common_dir, ( - {"CLASSICAL": signing_key_file} + { + "CLASSICAL": infra.consortium.slurp_bytes( + signing_key_file + ) + } if os.path.exists(signing_key_file) else None ),