diff --git a/cmake/common.cmake b/cmake/common.cmake index 20d5ac3a97f6..4d04e65bf044 100644 --- a/cmake/common.cmake +++ b/cmake/common.cmake @@ -91,7 +91,7 @@ add_custom_command( # Copy utilities from tests directory set(CCF_UTILITIES tests.sh keygenerator.sh cimetrics_env.sh - upload_pico_metrics.py scurl.sh + upload_pico_metrics.py scurl.sh submit_recovery_share.sh ) foreach(UTILITY ${CCF_UTILITIES}) configure_file( @@ -101,7 +101,8 @@ endforeach() # Install specific utilities install(PROGRAMS ${CCF_DIR}/tests/scurl.sh ${CCF_DIR}/tests/keygenerator.sh - ${CCF_DIR}/tests/sgxinfo.sh DESTINATION bin + ${CCF_DIR}/tests/sgxinfo.sh + ${CCF_DIR}/tests/submit_recovery_share.sh DESTINATION bin ) # Install getting_started scripts for VM creation and setup diff --git a/doc/members/accept_recovery.rst b/doc/members/accept_recovery.rst index 2aee743bd8c6..ae6991a3ace9 100644 --- a/doc/members/accept_recovery.rst +++ b/doc/members/accept_recovery.rst @@ -42,28 +42,27 @@ To restore private transactions and complete the recovery procedure, members sho .. note:: The members who submit their recovery shares do not necessarily have to be the members who previously accepted the recovery. -First, members should retrieve their encrypted recovery shares via the ``recovery_share`` RPC [#recovery_share]_. +The recovery share retrieval, decryption and submission steps are conveniently performed by the ``submit_recovery_share.sh`` script as follows: .. code-block:: bash - $ curl https:///members/recovery_share -X GET --cacert network_cert --key member1_privk --cert member1_cert -H "content-type: application/json" + $ ./submit_recovery_share.sh --rpc-address --member-enc-privk member0_enc_privk.pem --network-enc-pubk network_enc_pubk --cert member0_cert + --key member0_privk --cacert network_cert + HTTP/1.1 200 OK + content-type: text/plain + x-ccf-tx-seqno: 28 + x-ccf-tx-view: 4 + 1/2 recovery shares successfully submitted. -Then, members should decrypt their shares using their private encryption key and the `previous` network public encryption key (output by the first node of the now-defunct service via the ``network-enc-pubk-file`` :ref:`command line option `) using `NaCl's public-key authenticated encryption `_. + $ ./submit_recovery_share.sh --rpc-address --member-enc-privk member1_enc_privk.pem --network-enc-pubk network_enc_pubk --cert member1_cert + --key member1_privk --cacert network_cert + HTTP/1.1 200 OK + content-type: text/plain + x-ccf-tx-seqno: 30 + x-ccf-tx-view: 4 + 2/2 recovery shares successfully submitted. End of recovery procedure initiated. -Finally, members should submit their decrypted share to CCF via the ``recovery_share/submit`` RPC: - -.. code-block:: bash - - $ cat submit_recovery_share.json - {"recovery_share": []} - - $ curl https:///members/recovery_share/submit -X POST --data-binary @submit_recovery_share.json --cacert network_cert --key member1_privk --cert member1_cert -H "content-type: application/json" - false - - $ curl https:///members/recovery_share/submit -X POST --data-binary @submit_recovery_share.json --cacert network_cert --key member2_privk --cert member2_cert -H "content-type: application/json" - true - -When the recovery threshold is reached, the ``recovery_share/submit`` RPC returns ``true``. At this point, the private recovery procedure is started and the private ledger is being recovered. +When the recovery threshold is reached, the ``recovery_share/submit`` RPC returns that the end of the recovery procedure is initiated and the private ledger is now being recovered. .. note:: While all nodes are recovering the private ledger, no new transaction can be executed by the network. @@ -91,16 +90,16 @@ Summary Diagram Node 2-->>Member 1: State: ACCEPTED Note over Node 2, Node 3: accept_recovery proposal completes. Service is ready to accept recovery shares. - Member 0->>+Node 2: recovery_share + Member 0->>+Node 2: GET recovery_share Node 2-->>Member 0: Encrypted recovery share for Member 0 Note over Member 0: Decrypts recovery share - Member 0->>+Node 2: recovery_share/submit: {"recovery_share": ...} + Member 0->>+Node 2: POST recovery_share/submit: {"recovery_share": ...} Node 2-->>Member 0: False - Member 1->>+Node 2: recovery_share + Member 1->>+Node 2: GET recovery_share Node 2-->>Member 1: Encrypted recovery share for Member 1 Note over Member 1: Decrypts recovery share - Member 1->>+Node 2: recovery_share/submit: {"recovery_share": ...} + Member 1->>+Node 2: POST recovery_share/submit: {"recovery_share": ...} Node 2-->>Member 1: True Note over Node 2, Node 3: Reading Private Ledger... diff --git a/doc/members/adding_member.rst b/doc/members/adding_member.rst index 794a3d7319f9..7059a582100f 100644 --- a/doc/members/adding_member.rst +++ b/doc/members/adding_member.rst @@ -14,16 +14,16 @@ The ``keygenerator.sh`` script can be used to generate the member’s certificat .. code-block:: bash - $ keygenerator.sh --name=member_name --gen-enc-key + $ keygenerator.sh --name member_name --gen-enc-key -- Generating identity private key and certificate for participant "member_name"... Identity curve: secp384r1 Identity private key generated at: member_name_privk.pem Identity certificate generated at: member_name_cert.pem (to be registered in CCF) -- Generating encryption key pair for participant "member_name"... - Encryption private key generated at: member_name_enc_priv.pem - Encryption public key generated at: member_name_enc_pub.pem (to be registered in CCF) + Encryption private key generated at: member_name_enc_privk.pem + Encryption public key generated at: member_name_enc_pubk.pem (to be registered in CCF) -The member’s private keys (e.g. ``member_name_privk.pem`` and ``member_name_enc_priv.pem``) should be stored on a trusted device while the certificate (e.g. ``member_name_cert.pem``) and public encryption key (e.g. ``member_name_enc_pub.pem``) should be registered in CCF by members. +The member’s private keys (e.g. ``member_name_privk.pem`` and ``member_name_enc_privk.pem``) should be stored on a trusted device while the certificate (e.g. ``member_name_cert.pem``) and public encryption key (e.g. ``member_name_enc_pubk.pem``) should be registered in CCF by members. .. note:: See :ref:`developers/cryptography:Algorithms and Curves` for the list of supported cryptographic curves for member identity. diff --git a/doc/operators/start_network.rst b/doc/operators/start_network.rst index f6db78b6fc62..1240755fb9fc 100644 --- a/doc/operators/start_network.rst +++ b/doc/operators/start_network.rst @@ -34,7 +34,9 @@ CCF nodes can be started by using IP Addresses (both IPv4 and IPv6 are supported When starting up, the node generates its own key pair and outputs the certificate associated with its public key at the location specified by ``--node-cert-file``. The certificate of the freshly-created CCF network is also output at the location specified by ``--network-cert-file`` as well as the network encryption public key used by members during recovery via ``--network-enc-pubk-file``. -The certificates and recovery public keys of initial members of the consortium are specified via ``--member-info``. For example, if 3 members should be added to CCF, operators should specify ``--member-info member1_cert.pem,member1_enc_pub.pem``, ``--member-info member2_cert.pem,member2_enc_pub.pem``, ``--member-info member3_cert.pem,member3_enc_pub.pem``. +.. note:: The network certificate should be distributed to users and members to be used as the certificate authority (CA) when establishing a TLS connection with any of the nodes part of the CCF network. When using curl, this is passed as the ``--cacert`` argument. + +The certificates and recovery public keys of initial members of the consortium are specified via ``--member-info``. For example, if 3 members should be added to CCF, operators should specify ``--member-info member1_cert.pem,member1_enc_pubk.pem``, ``--member-info member2_cert.pem,member2_enc_pubk.pem``, ``--member-info member3_cert.pem,member3_enc_pubk.pem``. The :term:`Constitution`, as defined by the initial members, should be passed via the ``--gov-script`` option. @@ -112,7 +114,7 @@ Using a Configuration File [] network-cert-file = - member-info = "," + member-info = "," gov-script = .. code-block:: ini @@ -127,7 +129,7 @@ Using a Configuration File [] network-cert-file = - member-info = "," + member-info = "," gov-script = To pass configuration files, use the ``--config`` option: ``./cchost --config=config.ini``. An error will be generated if the configuration file contains extra fields. Options in the configuration file will be read along with normal command line arguments. Additional information for configuration files in CLI11 can be found `here `_. diff --git a/doc/schemas/recovery_share/submit_params.json b/doc/schemas/recovery_share/submit_params.json index e1cb5fd879a5..49c61c6fc795 100644 --- a/doc/schemas/recovery_share/submit_params.json +++ b/doc/schemas/recovery_share/submit_params.json @@ -1,18 +1,5 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "recovery_share": { - "items": { - "maximum": 255, - "minimum": 0, - "type": "integer" - }, - "type": "array" - } - }, - "required": [ - "recovery_share" - ], "title": "recovery_share/submit/params", - "type": "object" + "type": "string" } \ No newline at end of file diff --git a/doc/schemas/recovery_share/submit_result.json b/doc/schemas/recovery_share/submit_result.json index 6af279ebee53..19668eb75026 100644 --- a/doc/schemas/recovery_share/submit_result.json +++ b/doc/schemas/recovery_share/submit_result.json @@ -1,5 +1,5 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "recovery_share/submit/result", - "type": "boolean" + "type": "string" } \ No newline at end of file diff --git a/doc/schemas/recovery_share_result.json b/doc/schemas/recovery_share_result.json index 8d0c78d35af2..4b59a933ecff 100644 --- a/doc/schemas/recovery_share_result.json +++ b/doc/schemas/recovery_share_result.json @@ -1,26 +1,16 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "encrypted_share": { - "items": { - "maximum": 255, - "minimum": 0, - "type": "integer" - }, - "type": "array" + "encrypted_recovery_share": { + "type": "string" }, "nonce": { - "items": { - "maximum": 255, - "minimum": 0, - "type": "integer" - }, - "type": "array" + "type": "string" } }, "required": [ - "nonce", - "encrypted_share" + "encrypted_recovery_share", + "nonce" ], "title": "recovery_share/result", "type": "object" diff --git a/doc/users/index.rst b/doc/users/index.rst index f08485685937..5399667285f1 100644 --- a/doc/users/index.rst +++ b/doc/users/index.rst @@ -5,7 +5,7 @@ To generate the certificate and private key of trusted users should be generated .. code-block:: bash - $ CCF/tests/keygenerator.sh --name=user1 + $ CCF/tests/keygenerator.sh --name user1 -- Generating identity private key and certificate for participant "user1"... Identity private key generated at: user1_privk.pem Identity certificate generated at: user1_cert.pem (to be registered in CCF) diff --git a/src/node/rpc/member_frontend.h b/src/node/rpc/member_frontend.h index c0a7303e7bc8..e59c1fa60e24 100644 --- a/src/node/rpc/member_frontend.h +++ b/src/node/rpc/member_frontend.h @@ -30,12 +30,22 @@ namespace ccf DECLARE_JSON_REQUIRED_FIELDS(SetUserData, user_id) DECLARE_JSON_OPTIONAL_FIELDS(SetUserData, user_data) - struct SubmitRecoveryShare + struct GetEncryptedRecoveryShare { - std::vector recovery_share; + std::string encrypted_recovery_share; + std::string nonce; + + GetEncryptedRecoveryShare() = default; + + GetEncryptedRecoveryShare(const EncryptedShare& encrypted_share_raw) : + encrypted_recovery_share( + tls::b64_from_raw(encrypted_share_raw.encrypted_share)), + nonce(tls::b64_from_raw(encrypted_share_raw.nonce)) + {} }; - DECLARE_JSON_TYPE(SubmitRecoveryShare) - DECLARE_JSON_REQUIRED_FIELDS(SubmitRecoveryShare, recovery_share) + DECLARE_JSON_TYPE(GetEncryptedRecoveryShare) + DECLARE_JSON_REQUIRED_FIELDS( + GetEncryptedRecoveryShare, encrypted_recovery_share, nonce) class MemberHandlers : public CommonHandlerRegistry { @@ -822,78 +832,91 @@ namespace ccf Write) .set_auto_schema(); - auto get_encrypted_recovery_share = - [this](RequestArgs& args, nlohmann::json&& params) { - if (!check_member_active(args.tx, args.caller_id)) - { - return make_error( - HTTP_STATUS_FORBIDDEN, - "Only active members are given recovery shares"); - } + auto get_encrypted_recovery_share = [this]( + RequestArgs& args, + nlohmann::json&& params) { + if (!check_member_active(args.tx, args.caller_id)) + { + return make_error( + HTTP_STATUS_FORBIDDEN, + "Only active members are given recovery shares"); + } - auto encrypted_share = - share_manager.get_encrypted_share(args.tx, args.caller_id); + auto encrypted_share = + share_manager.get_encrypted_share(args.tx, args.caller_id); - if (!encrypted_share.has_value()) - { - return make_error( - HTTP_STATUS_BAD_REQUEST, - fmt::format( - "Recovery share not found for member {}", args.caller_id)); - } + if (!encrypted_share.has_value()) + { + return make_error( + HTTP_STATUS_BAD_REQUEST, + fmt::format( + "Recovery share not found for member {}", args.caller_id)); + } - return make_success(encrypted_share.value()); - }; + return make_success(GetEncryptedRecoveryShare(encrypted_share.value())); + }; install( MemberProcs::GET_ENCRYPTED_RECOVERY_SHARE, json_adapter(get_encrypted_recovery_share), Read) - .set_auto_schema() + .set_auto_schema() .set_http_get_only(); - auto submit_recovery_share = [this]( - RequestArgs& args, - nlohmann::json&& params) { + auto submit_recovery_share = [this](ccf::RequestArgs& args) { // Only active members can submit their shares for recovery if (!check_member_active(args.tx, args.caller_id)) { - return make_error(HTTP_STATUS_FORBIDDEN, "Member is not active"); + args.rpc_ctx->set_response_status(HTTP_STATUS_FORBIDDEN); + args.rpc_ctx->set_response_body("Member is not active"); + return; } GenesisGenerator g(this->network, args.tx); if ( g.get_service_status() != ServiceStatus::WAITING_FOR_RECOVERY_SHARES) { - return make_error( - HTTP_STATUS_FORBIDDEN, + args.rpc_ctx->set_response_status(HTTP_STATUS_FORBIDDEN); + args.rpc_ctx->set_response_body( "Service is not waiting for recovery shares"); + return; } if (node.is_reading_private_ledger()) { - return make_error( - HTTP_STATUS_FORBIDDEN, "Node is already recovering private ledger"); + args.rpc_ctx->set_response_status(HTTP_STATUS_FORBIDDEN); + args.rpc_ctx->set_response_body( + "Node is already recovering private ledger"); + return; } - const auto in = params.get(); + const auto& in = args.rpc_ctx->get_request_body(); + const auto s = std::string(in.begin(), in.end()); + auto raw_recovery_share = tls::raw_from_b64(s); + size_t submitted_shares_count = 0; try { submitted_shares_count = share_manager.submit_recovery_share( - args.tx, args.caller_id, in.recovery_share); + args.tx, args.caller_id, raw_recovery_share); } catch (const std::logic_error& e) { - return make_error( - HTTP_STATUS_INTERNAL_SERVER_ERROR, + args.rpc_ctx->set_response_status(HTTP_STATUS_INTERNAL_SERVER_ERROR); + args.rpc_ctx->set_response_body( fmt::format("Could not submit recovery share: {}", e.what())); + return; } if (submitted_shares_count < g.get_recovery_threshold()) { // The number of shares required to re-assemble the secret has not yet // been reached - return make_success(false); + args.rpc_ctx->set_response_status(HTTP_STATUS_OK); + args.rpc_ctx->set_response_body(fmt::format( + "{}/{} recovery shares successfully submitted.", + submitted_shares_count, + g.get_recovery_threshold())); + return; } LOG_DEBUG_FMT( @@ -907,19 +930,23 @@ namespace ccf { // For now, clear the submitted shares if combination fails. share_manager.clear_submitted_recovery_shares(args.tx); - return make_error( - HTTP_STATUS_INTERNAL_SERVER_ERROR, + args.rpc_ctx->set_response_status(HTTP_STATUS_INTERNAL_SERVER_ERROR); + args.rpc_ctx->set_response_body( fmt::format("Failed to initiate private recovery: {}", e.what())); + return; } share_manager.clear_submitted_recovery_shares(args.tx); - return make_success(true); + + args.rpc_ctx->set_response_status(HTTP_STATUS_OK); + args.rpc_ctx->set_response_body(fmt::format( + "{}/{} recovery shares successfully submitted. End of recovery " + "procedure initiated.", + submitted_shares_count, + g.get_recovery_threshold())); }; - install( - MemberProcs::SUBMIT_RECOVERY_SHARE, - json_adapter(submit_recovery_share), - Write) - .set_auto_schema(); + install(MemberProcs::SUBMIT_RECOVERY_SHARE, submit_recovery_share, Write) + .set_auto_schema(); auto create = [this](kv::Tx& tx, nlohmann::json&& params) { LOG_DEBUG_FMT("Processing create RPC"); diff --git a/src/node/rpc/test/member_voting_test.cpp b/src/node/rpc/test/member_voting_test.cpp index 4a639e579806..d4b1d0ca91db 100644 --- a/src/node/rpc/test/member_voting_test.cpp +++ b/src/node/rpc/test/member_voting_test.cpp @@ -74,6 +74,11 @@ T parse_response_body(const TResponse& r) return body_j.get(); } +std::string parse_response_body(const TResponse& r) +{ + return std::string(r.body.begin(), r.body.end()); +} + void check_error(const TResponse& r, http_status expected) { DOCTEST_CHECK(r.status == expected); @@ -92,6 +97,17 @@ void set_whitelists(GenesisGenerator& gen) gen.set_whitelist(wl.first, wl.second); } +std::vector create_text_request( + const std::string& text, + const string& method_name, + http_method verb = HTTP_POST) +{ + http::Request r(method_name, verb); + const auto body = std::vector(text.begin(), text.end()); + r.set_body(&body); + return r.build_request(); +} + std::vector create_request( const json& params, const string& method_name, http_method verb = HTTP_POST) { @@ -1643,17 +1659,20 @@ DOCTEST_TEST_CASE("Submit recovery shares") DOCTEST_INFO("Retrieve and decrypt recovery shares"); { - const auto get_recovery_shares = - create_request(nullptr, "recovery_share", HTTP_GET); + const auto get_recovery_shares = create_request( + nullptr, MemberProcs::GET_ENCRYPTED_RECOVERY_SHARE, HTTP_GET); for (auto const& m : members) { - auto encrypted_share = parse_response_body( + auto resp = parse_response_body( frontend_process(frontend, get_recovery_shares, m.second.first)); + auto encrypted_share = tls::raw_from_b64(resp.encrypted_recovery_share); + auto nonce = tls::raw_from_b64(resp.nonce); + retrieved_shares[m.first] = crypto::Box::open( - encrypted_share.encrypted_share, - encrypted_share.nonce, + encrypted_share, + nonce, crypto::BoxKey::public_from_private( network.encryption_key->private_raw), m.second.second); @@ -1663,9 +1682,9 @@ DOCTEST_TEST_CASE("Submit recovery shares") DOCTEST_INFO("Submit share before the service is in correct state"); { MemberId member_id = 0; - const auto submit_recovery_share = create_request( - SubmitRecoveryShare({retrieved_shares[member_id]}), - "recovery_share/submit"); + const auto submit_recovery_share = create_text_request( + tls::b64_from_raw(retrieved_shares[member_id]), + MemberProcs::SUBMIT_RECOVERY_SHARE); check_error( frontend_process( @@ -1696,8 +1715,9 @@ DOCTEST_TEST_CASE("Submit recovery shares") { auto bogus_recovery_share = retrieved_shares[m.first]; bogus_recovery_share[0] = bogus_recovery_share[0] + 1; - const auto submit_recovery_share = create_request( - SubmitRecoveryShare({bogus_recovery_share}), "recovery_share/submit"); + const auto submit_recovery_share = create_text_request( + tls::b64_from_raw(bogus_recovery_share), + MemberProcs::SUBMIT_RECOVERY_SHARE); auto rep = frontend_process(frontend, submit_recovery_share, m.second.first); @@ -1706,11 +1726,7 @@ DOCTEST_TEST_CASE("Submit recovery shares") // Share submission should only complete when the recovery threshold // has been reached - if (submitted_shares_count < recovery_threshold) - { - DOCTEST_REQUIRE(parse_response_body(rep) == false); - } - else + if (submitted_shares_count >= recovery_threshold) { check_error(rep, HTTP_STATUS_INTERNAL_SERVER_ERROR); break; @@ -1726,20 +1742,22 @@ DOCTEST_TEST_CASE("Submit recovery shares") size_t submitted_shares_count = 0; for (auto const& m : members) { - const auto submit_recovery_share = create_request( - SubmitRecoveryShare({retrieved_shares[m.first]}), - "recovery_share/submit"); + const auto submit_recovery_share = create_text_request( + tls::b64_from_raw(retrieved_shares[m.first]), + MemberProcs::SUBMIT_RECOVERY_SHARE); - auto ret = parse_response_body( - frontend_process(frontend, submit_recovery_share, m.second.first)); + auto rep = + frontend_process(frontend, submit_recovery_share, m.second.first); submitted_shares_count++; // Share submission should only complete when the recovery threshold // has been reached - DOCTEST_REQUIRE(ret == (submitted_shares_count >= recovery_threshold)); - if (ret) + if (submitted_shares_count >= recovery_threshold) { + DOCTEST_REQUIRE( + parse_response_body(rep).find( + "End of recovery procedure initiated.") != std::string::npos); break; } } diff --git a/src/node/share_manager.h b/src/node/share_manager.h index 3b9155787605..0416dceea88a 100644 --- a/src/node/share_manager.h +++ b/src/node/share_manager.h @@ -128,12 +128,12 @@ namespace ccf for (auto const& [member_id, enc_pub_key] : active_members_info) { auto nonce = tls::create_entropy()->random(crypto::Box::NONCE_SIZE); - auto share_raw = std::vector( + auto raw_share = std::vector( shares[share_index].begin(), shares[share_index].end()); auto enc_pub_key_raw = tls::PublicX25519::parse(tls::Pem(enc_pub_key)); auto encrypted_share = crypto::Box::create( - share_raw, + raw_share, nonce, enc_pub_key_raw, network.encryption_key->private_raw); diff --git a/src/tls/25519.h b/src/tls/25519.h index f6903d95d983..1c6f1ce248a0 100644 --- a/src/tls/25519.h +++ b/src/tls/25519.h @@ -13,7 +13,7 @@ namespace tls { - // This class parses and writes x25519 PEM keys following openssl + // This class parses and writes x25519 PEM keys following openssl // SubjectPublicKeyInfo DER format, generated by keygenerator.sh (e.g. for // members' public encryption key). Because the mbedtls version shipped with // Open Enclave does not (yet) support x25519 keys, we parse the key manually diff --git a/src/tls/base64.h b/src/tls/base64.h index 68ba6e801c1e..70622229b543 100644 --- a/src/tls/base64.h +++ b/src/tls/base64.h @@ -21,7 +21,7 @@ namespace tls auto rc = mbedtls_base64_decode(nullptr, 0, &len_written, data, size); if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) { - LOG_FAIL_FMT(fmt::format( + throw std::logic_error(fmt::format( "Could not obtain length of decoded base64 buffer: {}", error_string(rc))); } @@ -32,7 +32,7 @@ namespace tls decoded.data(), decoded.size(), &len_written, data, size); if (rc != 0) { - LOG_FAIL_FMT( + throw std::logic_error( fmt::format("Could not decode base64 string: {}", error_string(rc))); } @@ -47,7 +47,7 @@ namespace tls auto rc = mbedtls_base64_encode(nullptr, 0, &len_written, data, size); if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) { - LOG_FAIL_FMT(fmt::format( + throw std::logic_error(fmt::format( "Could not obtain length required for encoded base64 buffer: {}", error_string(rc))); } @@ -59,7 +59,7 @@ namespace tls mbedtls_base64_encode(dest, b64_string.size(), &len_written, data, size); if (rc != 0) { - LOG_FAIL_FMT( + throw std::logic_error( fmt::format("Could not encode base64 string: {}", error_string(rc))); } diff --git a/tests/infra/ccf.py b/tests/infra/ccf.py index b58d4c00362d..64ca0eedf5e7 100644 --- a/tests/infra/ccf.py +++ b/tests/infra/ccf.py @@ -57,6 +57,7 @@ def get_common_folder_name(workspace, label): class Network: KEY_GEN = "keygenerator.sh" + SHARE_SCRIPT = "submit_recovery_share.sh" DEFUNCT_NETWORK_ENC_PUBK = "network_enc_pubk_orig.pem" node_args_to_forward = [ "enclave_type", @@ -113,6 +114,7 @@ def __init__( self.common_dir = None self.election_duration = None self.key_generator = os.path.join(binary_dir, self.KEY_GEN) + self.share_script = os.path.join(binary_dir, self.SHARE_SCRIPT) if not os.path.isfile(self.key_generator): raise FileNotFoundError( f"Could not find key generator script at '{self.key_generator}' - is binary directory set correctly?" @@ -279,6 +281,7 @@ def start_and_join(self, args): self.consortium = infra.consortium.Consortium( self.common_dir, self.key_generator, + self.share_script, initial_member_ids, args.participants_curve, ) @@ -331,7 +334,7 @@ def start_in_recovery( # If a common directory was passed in, initialise the consortium from it if common_dir is not None: self.consortium = infra.consortium.Consortium( - common_dir, self.key_generator, remote_node=primary + common_dir, self.key_generator, self.share_script, remote_node=primary ) for node in self.get_joined_nodes(): @@ -456,8 +459,10 @@ def create_and_trust_node(self, lib_name, host, args, target_node=None): def create_user(self, user_id, curve, record=True): infra.proc.ccall( self.key_generator, - f"--name=user{user_id}", - f"--curve={curve.name}", + "--name", + f"user{user_id}", + "--curve", + f"{curve.name}", path=self.common_dir, log_output=False, ).check_returncode() diff --git a/tests/infra/checker.py b/tests/infra/checker.py index 6a658a586c27..47ff0bba6c41 100644 --- a/tests/infra/checker.py +++ b/tests/infra/checker.py @@ -71,20 +71,20 @@ def __call__(self, rpc_result, result=None, error=None, timeout=2): assert rpc_result.seqno and rpc_result.view, rpc_result - if self.client: - wait_for_global_commit(self.client, rpc_result.seqno, rpc_result.view) + if self.client: + wait_for_global_commit(self.client, rpc_result.seqno, rpc_result.view) - if self.notification_queue: - end_time = time.time() + timeout - while time.time() < end_time: - while self.notification_queue.not_empty: - notification = self.notification_queue.get() - n = json.loads(notification)["commit"] - assert ( - n > self.notified_commit - ), f"Received notification of commit {n} after commit {self.notified_commit}" - self.notified_commit = n - if n >= rpc_result.seqno: - return - time.sleep(0.5) - raise TimeoutError("Timed out waiting for notification") + if self.notification_queue: + end_time = time.time() + timeout + while time.time() < end_time: + while self.notification_queue.not_empty: + notification = self.notification_queue.get() + n = json.loads(notification)["commit"] + assert ( + n > self.notified_commit + ), f"Received notification of commit {n} after commit {self.notified_commit}" + self.notified_commit = n + if n >= rpc_result.seqno: + return + time.sleep(0.5) + raise TimeoutError("Timed out waiting for notification") diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index f6d90dfe0dec..1abfa03f87bd 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -19,18 +19,27 @@ class Consortium: def __init__( - self, common_dir, key_generator, member_ids=None, curve=None, remote_node=None + self, + common_dir, + key_generator, + share_script, + member_ids=None, + curve=None, + remote_node=None, ): self.common_dir = common_dir self.members = [] self.key_generator = key_generator + self.share_script = share_script self.members = [] self.recovery_threshold = None # If a list of member IDs is passed in, generate fresh member identities. # Otherwise, recover the state of the consortium from the state of CCF. if member_ids is not None: for m_id in member_ids: - new_member = infra.member.Member(m_id, curve, common_dir, key_generator) + new_member = infra.member.Member( + m_id, curve, common_dir, share_script, key_generator + ) new_member.set_active() self.members.append(new_member) self.recovery_threshold = len(self.members) @@ -51,7 +60,9 @@ def __init__( }, ) for m in r.result or []: - new_member = infra.member.Member(m[0], curve, self.common_dir) + new_member = infra.member.Member( + m[0], curve, self.common_dir, share_script + ) if ( infra.member.MemberStatus[m[1]] == infra.member.MemberStatus.ACTIVE @@ -75,7 +86,7 @@ def generate_and_propose_new_member(self, remote_node, curve): # should ACK to become active. new_member_id = len(self.members) new_member = infra.member.Member( - new_member_id, curve, self.common_dir, self.key_generator + new_member_id, curve, self.common_dir, self.share_script, self.key_generator ) script = """ @@ -87,7 +98,7 @@ def generate_and_propose_new_member(self, remote_node, curve): ) as cert: new_member_cert_pem = [ord(c) for c in cert.read()] with open( - os.path.join(self.common_dir, f"member{new_member_id}_enc_pub.pem") + os.path.join(self.common_dir, f"member{new_member_id}_enc_pubk.pem") ) as keyshare: new_member_keyshare = [ord(k) for k in keyshare.read()] @@ -111,7 +122,7 @@ def generate_and_add_new_member(self, remote_node, curve): def get_members_info(self): members_certs = [f"member{m.member_id}_cert.pem" for m in self.members] - members_enc_pub = [f"member{m.member_id}_enc_pub.pem" for m in self.members] + members_enc_pub = [f"member{m.member_id}_enc_pubk.pem" for m in self.members] return list(zip(members_certs, members_enc_pub)) def get_active_members(self): @@ -315,15 +326,21 @@ def accept_recovery(self, remote_node): def recover_with_shares(self, remote_node, defunct_network_enc_pubk): submitted_shares_count = 0 - for m in self.get_active_members(): - decrypted_share = m.get_and_decrypt_recovery_share( - remote_node, defunct_network_enc_pubk - ) - r = m.submit_recovery_share(remote_node, decrypted_share) - submitted_shares_count += 1 - assert r.result == (submitted_shares_count >= self.recovery_threshold) - if submitted_shares_count >= self.recovery_threshold: - break + with remote_node.node_client() as nc: + check_commit = infra.checker.Checker(nc) + + for m in self.get_active_members(): + r = m.get_and_submit_recovery_share( + remote_node, defunct_network_enc_pubk + ) + submitted_shares_count += 1 + check_commit(r) + + if submitted_shares_count >= self.recovery_threshold: + assert "End of recovery procedure initiated" in r.result + break + else: + assert "End of recovery procedure initiated" not in r.result def set_recovery_threshold(self, remote_node, recovery_threshold): script = """ diff --git a/tests/infra/member.py b/tests/infra/member.py index 3f029f5493ff..4c943a201d41 100644 --- a/tests/infra/member.py +++ b/tests/infra/member.py @@ -8,6 +8,7 @@ import infra.crypto import http import os +import base64 class NoRecoveryShareFound(Exception): @@ -23,19 +24,21 @@ class MemberStatus(Enum): class Member: - def __init__(self, member_id, curve, common_dir, key_generator=None): - self.key_generator = key_generator + def __init__(self, member_id, curve, common_dir, share_script, key_generator=None): self.common_dir = common_dir self.member_id = member_id self.status = MemberStatus.ACCEPTED + self.share_script = share_script if key_generator is not None: # For now, all members are given an encryption key (for recovery) member = f"member{member_id}" infra.proc.ccall( - self.key_generator, - f"--name={member}", - f"--curve={curve.name}", + key_generator, + "--name", + f"{member}", + "--curve", + f"{curve.name}", "--gen-enc-key", path=self.common_dir, log_output=False, @@ -148,22 +151,34 @@ def get_and_decrypt_recovery_share(self, remote_node, defunct_network_enc_pubk): if r.status != http.HTTPStatus.OK.value: raise NoRecoveryShareFound(r) - # Members rely on a copy of the original network encryption public - # key to decrypt their recovery shares ctx = infra.crypto.CryptoBoxCtx( - os.path.join(self.common_dir, f"member{self.member_id}_enc_priv.pem"), + os.path.join(self.common_dir, f"member{self.member_id}_enc_privk.pem"), defunct_network_enc_pubk, ) - nonce_bytes = bytes(r.result["nonce"]) - encrypted_share_bytes = bytes(r.result["encrypted_share"]) + nonce_bytes = base64.b64decode(r.result["nonce"]) + encrypted_share_bytes = base64.b64decode( + r.result["encrypted_recovery_share"] + ) return ctx.decrypt(encrypted_share_bytes, nonce_bytes) - def submit_recovery_share(self, remote_node, decrypted_recovery_share): - with remote_node.member_client(member_id=self.member_id) as mc: - r = mc.rpc( - "recovery_share/submit", - params={"recovery_share": list(decrypted_recovery_share)}, - ) - assert r.error is None, f"Error submitting recovery share: {r.error}" - return r + def get_and_submit_recovery_share(self, remote_node, defunct_network_enc_pubk): + # For now, all members are given an encryption key (for recovery) + res = infra.proc.ccall( + self.share_script, + "--rpc-address", + f"{remote_node.host}:{remote_node.rpc_port}", + "--member-enc-privk", + os.path.join(self.common_dir, f"member{self.member_id}_enc_privk.pem"), + "--network-enc-pubk", + defunct_network_enc_pubk, + "--cert", + os.path.join(self.common_dir, f"member{self.member_id}_cert.pem"), + "--key", + os.path.join(self.common_dir, f"member{self.member_id}_privk.pem"), + "--cacert", + os.path.join(self.common_dir, "networkcert.pem"), + log_output=True, + ) + res.check_returncode() + return infra.clients.Response.from_raw(res.stdout) diff --git a/tests/keygenerator.sh b/tests/keygenerator.sh index ee29318a4f7f..ec9a5332a3c7 100755 --- a/tests/keygenerator.sh +++ b/tests/keygenerator.sh @@ -20,27 +20,27 @@ generate_encryption_key=false function usage() { + echo "Usage:" + echo " $0 --name participant_name [--curve $DEFAULT_CURVE] [--gen-enc-key]" echo "Generates identity private key and self-signed certificates for CCF participants." echo "Optionally generates a x25519 key pair for share encryption (required for consortium members)." - echo "Usage:""" - echo " $0 --name=participant_name [--curve=$DEFAULT_CURVE] [--gen-enc-key]" echo "" echo "Supported curves are: $SUPPORTED_CURVES" } while [ "$1" != "" ]; do - PARAM=${1%=*} - VALUE=${1#*=} - case $PARAM in + case $1 in -h|-\?|--help) usage exit 0 ;; -n|--name) - name="$VALUE" + name="$2" + shift ;; -c|--curve) - curve="$VALUE" + curve="$2" + shift ;; -g|--gen-enc-key) generate_encryption_key=true @@ -53,7 +53,7 @@ done # Validate parameters if [ -z "$name" ]; then - echo "The name of the participant should be specified (e.g. member0 or user1)" + echo "Error: The name of the participant should be specified (e.g. member0 or user1)" exit 1 fi @@ -92,8 +92,8 @@ echo "Identity certificate generated at: $cert (to be registered in CCF)" if "$generate_encryption_key"; then echo "-- Generating encryption key pair for participant \"$name\"..." - enc_priv="$name"_enc_priv.pem - enc_pub="$name"_enc_pub.pem + enc_priv="$name"_enc_privk.pem + enc_pub="$name"_enc_pubk.pem openssl genpkey -out "$enc_priv" -algorithm "$ENCRYPTION_CURVE" openssl pkey -in "$enc_priv" -pubout -out "$enc_pub" diff --git a/tests/recovery.py b/tests/recovery.py index 1230a21940ec..87cfb45bd761 100644 --- a/tests/recovery.py +++ b/tests/recovery.py @@ -51,11 +51,8 @@ def test_share_resilience(network, args): break check_commit = infra.checker.Checker(nc) - decrypted_share = m.get_and_decrypt_recovery_share( - primary, defunct_network_enc_pubk - ) check_commit( - m.submit_recovery_share(primary, decrypted_share), result=False + m.get_and_submit_recovery_share(primary, defunct_network_enc_pubk) ) submitted_shares_count += 1 @@ -81,10 +78,9 @@ def test_share_resilience(network, args): new_primary is not primary ), f"Primary {primary.node_id} should have changed after election" - decrypted_share = last_member_to_submit.get_and_decrypt_recovery_share( + last_member_to_submit.get_and_submit_recovery_share( new_primary, defunct_network_enc_pubk ) - last_member_to_submit.submit_recovery_share(new_primary, decrypted_share) for node in recovered_network.get_joined_nodes(): recovered_network.wait_for_state( diff --git a/tests/scurl.sh b/tests/scurl.sh index e590b18cc2b6..24fd70e2c24d 100755 --- a/tests/scurl.sh +++ b/tests/scurl.sh @@ -25,12 +25,12 @@ for item in "$@" ; do done if [ -z "$request" ]; then - echo "No request found in arguments (-d or --data-binary)" + echo "Error: No request found in arguments (-d or --data-binary)" exit 1 fi if [ -z "$privk" ]; then - echo "No private key found in arguments (--key)" + echo "Error: No private key found in arguments (--key)" exit 1 fi diff --git a/tests/submit_recovery_share.sh b/tests/submit_recovery_share.sh new file mode 100755 index 000000000000..2494104a48f4 --- /dev/null +++ b/tests/submit_recovery_share.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +set -e + +function usage() +{ + echo "Usage:""" + echo " $0 --rpc-address rpc_address --member-enc-privk member_enc_privk.pem --network-enc-pubk network_enc_pubk.pem [CURL_OPTIONS]" + echo "Retrieves the encrypted recovery share for a given member, decrypts the share and submits it for recovery." + echo "" + echo "A sufficient number of recovery shares must be submitted by members to initiate the end of recovery procedure." + echo "Note: Requires step CLI." +} + +while [ "$1" != "" ]; do + case $1 in + -h|-\?|--help) + usage + exit 0 + ;; + --rpc-address) + node_rpc_address="$2" + ;; + --member-enc-privk) + member_enc_privk="$2" + ;; + --network-enc-pubk) + network_enc_pubk="$2" + ;; + *) + break + esac + shift + shift +done + +if ! [ -x "$(command -v step)" ]; then + echo "Error: step CLI is not installed on your system or not in your path." + echo "See https://microsoft.github.io/CCF/master/members/accept_recovery.html#submitting-recovery-shares" + exit 1 +fi + +if [ -z "$node_rpc_address" ]; then + echo "Error: No node RPC address in arguments (--rpc-address)" + exit 1 +fi + +if [ -z "$member_enc_privk" ]; then + echo "Error: No member encryption private key in arguments (--member-enc-privk)" + exit 1 +fi + +if [ -z "$network_enc_pubk" ]; then + echo "Error: No defunct network encryption public key in arguments (--network-enc-pubk)" + exit 1 +fi + +# Retrieve encrypted recovery share and nonce +resp=$(curl -sS --fail -X GET https://"${node_rpc_address}"/members/recovery_share "${@}") +encrypted_share="$(echo "${resp}" | jq -r .encrypted_recovery_share)" +nonce="$(echo "${resp}" | jq -r .nonce)" + +# Temporary directory for raw keys +tmp_dir=$(mktemp -d) +function cleanup() { + rm -rf "${tmp_dir}" +} +trap cleanup EXIT + +# Parse raw private key from SubjectPublicKeyInfo DER format, as generated by keygenerator.sh --gen-enc-key +der_header_privk_len=14 +openssl asn1parse -in "${member_enc_privk}" -strparse "${der_header_privk_len}" -out "${tmp_dir}/member_enc_privk.raw" -noout + +# Parse raw public key generated by network +der_header_pubk_len=9 +openssl asn1parse -in "${network_enc_pubk}" -i -strparse "${der_header_pubk_len}" -out "${tmp_dir}/network_enc_pubk.raw" -noout + +# step CLI base64 standard to URL encoding for input +encrypted_share_b64_url=$(echo "${encrypted_share}" | sed 's/+/-/g; s/\//_/g') + +# Decrypt encrypted share with nonce, member private key and defunct network public key and submit share +# All in one line so that the recovery share is not exposed +echo "${encrypted_share_b64_url}" | step crypto nacl box open base64:"${nonce}" "${tmp_dir}/network_enc_pubk.raw" "${tmp_dir}/member_enc_privk.raw" | openssl base64 -A | curl -i -sS --fail https://"${node_rpc_address}"/members/recovery_share/submit "${@}" -d @- \ No newline at end of file