diff --git a/CHANGELOG.md b/CHANGELOG.md index b25e90b3d4ca..4cbfa7cfe3b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [4.0.0-dev7]: https://github.com/microsoft/CCF/releases/tag/ccf-4.0.0-dev7 +### Deprecated + +- `GET /gov/recovery_share` is deprecated in favour of the unauthenticated `GET /gov/encrypted_recovery_share/{member_id}`. +- The `submit_recovery_share.sh` script now takes a `--cert` argument. + ### Added - Added missing `ccf.gov.msg.type` value `encrypted_recovery_share` to `ccf_cose_sign1*` scripts. diff --git a/cadl-ccf/main.cadl b/cadl-ccf/main.cadl index ee697dc4a086..5faf808c859c 100644 --- a/cadl-ccf/main.cadl +++ b/cadl-ccf/main.cadl @@ -290,7 +290,7 @@ model Ballot { ballot: string; } -model GetRecoveryShare { +model GetEncryptedRecoveryShare { encryptedShare: string; } @@ -551,17 +551,16 @@ namespace proposal { }; @route("/gov/recovery_share") -namespace recover { - @summary("A member's recovery share") - @get op getRecoveryShare(): { - @body recoveryShare: GetRecoveryShare; - }; +@summary("Provide a recovery share for the purpose of completing a service recovery") +@post op postRecoveryShare(@body recoveryShare: SubmitRecoveryShareIn): { + @body recoveryShare: SubmitRecoveryShareOut; +}; - @summary("Provide a recovery share for the purpose of completing a service recovery") - @post op postRecoveryShare(@body recoveryShare: SubmitRecoveryShareIn): { - @body recoveryShare: SubmitRecoveryShareOut; - }; -} +@route("/gov/encrypted_recovery_share/{member_id}") +@summary("A member's encrypted recovery share") +@get op getEncryptedRecoveryShare(@path member_id: string): { + @body recoveryShare: GetEncryptedRecoveryShare; +}; @route("/gov/tx") @doc("Possible statuses returned are Unknown, Pending, Committed or Invalid.") diff --git a/doc/governance/accept_recovery.rst b/doc/governance/accept_recovery.rst index 5f91f58ee4f1..6341e5dcb962 100644 --- a/doc/governance/accept_recovery.rst +++ b/doc/governance/accept_recovery.rst @@ -95,7 +95,7 @@ To restore private transactions and complete the recovery procedure, recovery me .. note:: The recovery members who submit their recovery shares do not necessarily have to be the members who previously accepted the recovery. -Member recovery shares are stored in the ledger, encrypted with each member's public encryption key. Members can retrieve their encrypted recovery shares from the public-only service via the :http:GET:`/gov/recovery_share` endpoint, perform the share decryption securely (see for example :doc:`hsm_keys`) and submit the decrypted recovery share via the :http:POST:`/gov/recovery_share` endpoint. +Member recovery shares are stored in the ledger, encrypted with each member's public encryption key. Members can retrieve their encrypted recovery shares from the public-only service via the :http:GET:`/gov/encrypted_recovery_share/{member_id}` endpoint, perform the share decryption securely (see for example :doc:`hsm_keys`) and submit the decrypted recovery share via the :http:POST:`/gov/recovery_share` endpoint. The recovery share retrieval, decryption and submission steps can be conveniently performed in one step using the ``submit_recovery_share.sh`` script: @@ -143,13 +143,13 @@ Summary Diagram Node 2-->>Member 1: State: Accepted Note over Node 2, Node 3: transition_service_to_open proposal completes.
Service is ready to accept recovery shares. - Member 0->>+Node 2: GET /gov/recovery_share + Member 0->>+Node 2: GET /gov/encrypted_recovery_share/ Node 2-->>Member 0: Encrypted recovery share for Member 0 Note over Member 0: Decrypts recovery share Member 0->>+Node 2: POST /gov/recovery_share: "" Node 2-->>Member 0: 1/2 recovery shares successfully submitted. - Member 1->>+Node 2: GET /gov/recovery_share + Member 1->>+Node 2: GET /gov/encrypted_recovery_share/ Node 2-->>Member 1: Encrypted recovery share for Member 1 Note over Member 1: Decrypts recovery share Member 1->>+Node 2: POST /gov/recovery_share: "" diff --git a/doc/governance/hsm_keys.rst b/doc/governance/hsm_keys.rst index 5b77207c5463..3e847d55be98 100644 --- a/doc/governance/hsm_keys.rst +++ b/doc/governance/hsm_keys.rst @@ -159,7 +159,7 @@ Finally, the signed HTTP request can be issued, using the request headers printe Recovery Share Decryption ------------------------- -To retrieve their encrypted recovery share, a member should issue a COSE Sign1 or signed HTTP request against the ``/gov/recovery_share`` endpoint (see :ref:`governance/accept_recovery:Submitting Recovery Shares`). Signing the request will allow the member to authenticate themself to CCF (see :ref:`governance/hsm_keys:Signing Governance Requests`). +A member can fetch their encrypted recovery share through :http:GET:`/gov/encrypted_recovery_share/{member_id}` (see :ref:`governance/accept_recovery:Submitting Recovery Shares`). The retrieved encrypted recovery share can be decrypted with the encryption key stored in Key Vault: diff --git a/doc/schemas/gov_openapi.json b/doc/schemas/gov_openapi.json index 245678131370..974f1ca63cd5 100644 --- a/doc/schemas/gov_openapi.json +++ b/doc/schemas/gov_openapi.json @@ -1271,7 +1271,7 @@ "info": { "description": "This API is used to submit and query proposals which affect CCF's public governance tables.", "title": "CCF Governance API", - "version": "2.24.0" + "version": "2.25.0" }, "openapi": "3.0.0", "paths": { @@ -1453,6 +1453,39 @@ } } }, + "/gov/encrypted_recovery_share/{member_id}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetRecoveryShare__Out" + } + } + }, + "description": "Default response description" + }, + "default": { + "$ref": "#/components/responses/default" + } + }, + "summary": "A member's recovery share", + "x-ccf-forwarding": { + "$ref": "#/components/x-ccf-forwarding/always" + } + }, + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, "/gov/jwt_keys/all": { "get": { "deprecated": true, @@ -2480,6 +2513,7 @@ }, "/gov/recovery_share": { "get": { + "deprecated": true, "responses": { "200": { "content": { diff --git a/python/utils/submit_recovery_share.sh b/python/utils/submit_recovery_share.sh index c5804acfa910..ae6dc54270a6 100755 --- a/python/utils/submit_recovery_share.sh +++ b/python/utils/submit_recovery_share.sh @@ -7,7 +7,7 @@ set -e function usage() { echo "Usage:""" - echo " $0 https:// --member-enc-privk /path/to/member_enc_privk.pem [CURL_OPTIONS]" + echo " $0 https:// --member-enc-privk /path/to/member_enc_privk.pem --cert /path/to/member_cert.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." @@ -41,13 +41,34 @@ while [ "$1" != "" ]; do shift done +# Loop through all arguments and find cert +next_is_cert=false + +for item in "$@" ; do + if [ "$next_is_cert" == true ]; then + cert=$item + next_is_cert=false + fi + if [ "$item" == "--cert" ]; then + next_is_cert=true + fi +done + if [ -z "${member_enc_privk}" ]; then echo "Error: No member encryption private key in arguments (--member-enc-privk)" exit 1 fi +if [ -z "${cert}" ]; then + echo "Error: No user certificate in arguments (--cert)" + exit 1 +fi + +# Compute member ID, as the SHA-256 fingerprint of the signing certificate +member_id=$(openssl x509 -in "$cert" -noout -fingerprint -sha256 | cut -d "=" -f 2 | sed 's/://g' | awk '{print tolower($0)}') + # First, retrieve the encrypted recovery share -encrypted_share=$(curl -sS --fail -X GET "${node_rpc_address}"/gov/recovery_share "${@}" | jq -r '.encrypted_share') +encrypted_share=$(curl -sS --fail -X GET "${node_rpc_address}"/gov/encrypted_recovery_share/"${member_id}" "${@}" | jq -r '.encrypted_share') # Then, decrypt encrypted share with member private key submit decrypted recovery share # Note: all in one line so that the decrypted recovery share is not exposed diff --git a/src/node/rpc/member_frontend.h b/src/node/rpc/member_frontend.h index 2c883265b1f8..77e7f141b9cb 100644 --- a/src/node/rpc/member_frontend.h +++ b/src/node/rpc/member_frontend.h @@ -587,7 +587,7 @@ namespace ccf openapi_info.description = "This API is used to submit and query proposals which affect CCF's " "public governance tables."; - openapi_info.document_version = "2.24.0"; + openapi_info.document_version = "2.25.0"; } static std::optional get_caller_member_id( @@ -901,6 +901,54 @@ namespace ccf get_encrypted_recovery_share, member_cert_or_sig_policies("encrypted_recovery_share")) .set_auto_schema() + .set_openapi_deprecated(true) + .set_openapi_summary( + "This endpoint is deprecated. It is replaced by " + "/encrypted_recovery_share/{member_id}") + .set_openapi_summary("A member's recovery share") + .install(); + + auto get_encrypted_recovery_share_for_member = + [this](ccf::endpoints::EndpointContext& ctx) { + std::string error_msg; + MemberId member_id; + if (!get_member_id_from_path( + ctx.rpc_ctx->get_request_path_params(), member_id, error_msg)) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_BAD_REQUEST, + ccf::errors::InvalidResourceName, + std::move(error_msg)); + return; + } + + auto encrypted_share = + share_manager.get_encrypted_share(ctx.tx, member_id); + + if (!encrypted_share.has_value()) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_NOT_FOUND, + ccf::errors::ResourceNotFound, + fmt::format( + "Recovery share not found for member {}.", member_id)); + return; + } + + auto rec_share = GetRecoveryShare::Out{ + crypto::b64_from_raw(encrypted_share.value())}; + ctx.rpc_ctx->set_response_header( + http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON); + ctx.rpc_ctx->set_response_body(nlohmann::json(rec_share).dump()); + ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK); + return; + }; + make_endpoint( + "/encrypted_recovery_share/{member_id}", + HTTP_GET, + get_encrypted_recovery_share_for_member, + ccf::no_auth_required) + .set_auto_schema() .set_openapi_summary("A member's recovery share") .install(); diff --git a/tests/infra/member.py b/tests/infra/member.py index 492d73bac671..e37cfdd4b8a3 100644 --- a/tests/infra/member.py +++ b/tests/infra/member.py @@ -193,8 +193,8 @@ def get_and_decrypt_recovery_share(self, remote_node): if not self.is_recovery_member: raise ValueError(f"Member {self.local_id} does not have a recovery share") - with remote_node.client(*self.auth()) as mc: - r = mc.get("/gov/recovery_share") + with remote_node.client() as mc: + r = mc.get(f"/gov/encrypted_recovery_share/{self.service_id}") if r.status_code != http.HTTPStatus.OK.value: raise NoRecoveryShareFound(r) diff --git a/tests/membership.py b/tests/membership.py index cb8aa599225e..18004dd930dd 100644 --- a/tests/membership.py +++ b/tests/membership.py @@ -178,13 +178,16 @@ def recovery_shares_scenario(args): LOG.info("Non-recovery member does not have a recovery share") primary, _ = network.find_primary() - with primary.client(non_recovery_member_id) as mc: - r = mc.get("/gov/recovery_share") + with primary.client() as mc: + mid = network.consortium.get_member_by_local_id( + non_recovery_member_id + ).service_id + r = mc.get(f"/gov/encrypted_recovery_share/{mid}") assert r.status_code == http.HTTPStatus.NOT_FOUND.value assert ( - f"Recovery share not found for member {network.consortium.get_member_by_local_id(non_recovery_member_id).service_id}" + f"Recovery share not found for member m[{network.consortium.get_member_by_local_id(non_recovery_member_id).service_id}]" in r.body.json()["error"]["message"] - ) + ), r.body.json()["error"] # Removing a recovery number is not possible as the number of recovery # members would be under recovery threshold (2)