diff --git a/Library/Homebrew/api.rb b/Library/Homebrew/api.rb index 6859017b08..c865ad13ff 100644 --- a/Library/Homebrew/api.rb +++ b/Library/Homebrew/api.rb @@ -56,7 +56,7 @@ module Homebrew (Homebrew::EnvConfig.no_auto_update? || ((Time.now - Homebrew::EnvConfig.api_auto_update_secs.to_i) < target.mtime)) - begin + json_data = begin begin args = curl_args.dup args.prepend("--time-cond", target.to_s) if target.exist? && !target.empty? @@ -83,7 +83,7 @@ module Homebrew end FileUtils.touch(target) unless skip_download - [JSON.parse(target.read), !skip_download] + JSON.parse(target.read) rescue JSON::ParserError target.unlink retry_count += 1 @@ -92,6 +92,21 @@ module Homebrew retry end + + if endpoint.end_with?(".jws.json") + success, data = verify_and_parse_jws(json_data) + unless success + target.unlink + odie <<~EOS + Failed to verify integrity (#{data}) of: + #{url} + Potential MITM attempt detected. Please run `brew update` and try again. + EOS + end + [data, !skip_download] + else + [json_data, !skip_download] + end end sig { params(name: String, git_head: T.nilable(String)).returns(String) } @@ -140,5 +155,31 @@ module Homebrew false end + + sig { params(json_data: Hash).returns([T::Boolean, T.any(String, Array, Hash)]) } + private_class_method def self.verify_and_parse_jws(json_data) + signatures = json_data["signatures"] + homebrew_signature = signatures&.find { |sig| sig.dig("header", "kid") == "homebrew-1" } + return false, "key not found" if homebrew_signature.nil? + + header = JSON.parse(Base64.urlsafe_decode64(homebrew_signature["protected"])) + if header["alg"] != "PS512" || header["b64"] != false # NOTE: nil has a meaning of true + return false, "invalid algorithm" + end + + require "openssl" + + pubkey = OpenSSL::PKey::RSA.new((HOMEBREW_LIBRARY_PATH/"api/homebrew-1.pem").read) + signing_input = "#{homebrew_signature["protected"]}.#{json_data["payload"]}" + unless pubkey.verify_pss("SHA512", + Base64.urlsafe_decode64(homebrew_signature["signature"]), + signing_input, + salt_length: :digest, + mgf1_hash: "SHA512") + return false, "signature mismatch" + end + + [true, JSON.parse(json_data["payload"])] + end end end diff --git a/Library/Homebrew/api/cask.rb b/Library/Homebrew/api/cask.rb index d5308372ed..a4fbcb4646 100644 --- a/Library/Homebrew/api/cask.rb +++ b/Library/Homebrew/api/cask.rb @@ -27,8 +27,8 @@ module Homebrew sig { returns(T::Boolean) } def download_and_cache_data! - json_casks, updated = Homebrew::API.fetch_json_api_file "cask.json", - target: HOMEBREW_CACHE_API/"cask.json" + json_casks, updated = Homebrew::API.fetch_json_api_file "cask.jws.json", + target: HOMEBREW_CACHE_API/"cask.jws.json" cache["casks"] = json_casks.to_h do |json_cask| [json_cask["token"], json_cask.except("token")] diff --git a/Library/Homebrew/api/formula.rb b/Library/Homebrew/api/formula.rb index f8ef3a9809..a93fa4043d 100644 --- a/Library/Homebrew/api/formula.rb +++ b/Library/Homebrew/api/formula.rb @@ -22,8 +22,8 @@ module Homebrew sig { returns(T::Boolean) } def download_and_cache_data! - json_formulae, updated = Homebrew::API.fetch_json_api_file "formula.json", - target: HOMEBREW_CACHE_API/"formula.json" + json_formulae, updated = Homebrew::API.fetch_json_api_file "formula.jws.json", + target: HOMEBREW_CACHE_API/"formula.jws.json" cache["aliases"] = {} cache["formulae"] = json_formulae.to_h do |json_formula| diff --git a/Library/Homebrew/api/homebrew-1.pem b/Library/Homebrew/api/homebrew-1.pem new file mode 100644 index 0000000000..ba15b24146 --- /dev/null +++ b/Library/Homebrew/api/homebrew-1.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyKoOYzp1rhwXISRi61BY +XBEr2PalSK8lEVOL2USy7mpy0OubOlFyujawyQcBcCn+uPOJ/WaK+POhNWcLLoiK +L2m8GViaQm7SMwdLKUXFgKSPHcG/1m6Vu+TNBKTfQqT60PjEYIrn5NW9ZrM0cUhK +REmsbeAMBevdSaW9UwY9iIhprrgovvT8SzKhF8ZOIZKXfJX4VNk0y/7VJYNuGGqH +3npxV7OKd4yTGRGqFcC9kJ84me3thiu0yqlOjASmfWIwIwcfp4j6BEM2LuqKd7yX +h51/O+MTthkuxV36moDKfdgdOFsvlCFkziaYLScCX9lOlmZHtOfJTAOXxTmM7qGr +wTGK0vhvTi8k9dBmH/dccredQBtPOfM/FEdeyakGLoTcDguiBS/4El3I2KtF6B2h +OGoBumR915/cI4drr5yPMduZ7gjs7ZEZnVkeVzic24TfUHpnOYzrhucNJtHMBDj9 +6d1Gk82AhtuF9KlusLmCb6qXCWQSp/A4RZpN37E/p9q8rLp/7B/zp8X2TVvecPNy +BdMagdktdEqK7WPlYMcUp56JaOph8vqYoU+oGyCpWoLvcXFb75o4eefuu6Rs5SyM +c9JCCJ0DDFPjCRFnGPkvsKxFCzMFqH1jpWH0RQIrgmNVM5PO84iRH9YJsSPQzpMj +KvK/ZH4YgR9wNkBNagFo7lsCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/Library/Homebrew/cmd/update.sh b/Library/Homebrew/cmd/update.sh index 0d158b306c..85fd79cf70 100644 --- a/Library/Homebrew/cmd/update.sh +++ b/Library/Homebrew/cmd/update.sh @@ -766,28 +766,28 @@ EOS for formula_or_cask in formula cask do - if [[ -f "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" ]] + if [[ -f "${HOMEBREW_CACHE}/api/${formula_or_cask}.jws.json" ]] then - INITIAL_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/"${formula_or_cask}".json)" + INITIAL_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/"${formula_or_cask}".jws.json)" fi JSON_URLS=() if [[ -n "${HOMEBREW_API_DOMAIN}" && "${HOMEBREW_API_DOMAIN}" != "${HOMEBREW_API_DEFAULT_DOMAIN}" ]] then - JSON_URLS=("${HOMEBREW_API_DOMAIN}/${formula_or_cask}.json") + JSON_URLS=("${HOMEBREW_API_DOMAIN}/${formula_or_cask}.jws.json") fi - JSON_URLS+=("${HOMEBREW_API_DEFAULT_DOMAIN}/${formula_or_cask}.json") + JSON_URLS+=("${HOMEBREW_API_DEFAULT_DOMAIN}/${formula_or_cask}.jws.json") for json_url in "${JSON_URLS[@]}" do time_cond=() - if [[ -s "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" ]] + if [[ -s "${HOMEBREW_CACHE}/api/${formula_or_cask}.jws.json" ]] then - time_cond=("--time-cond" "${HOMEBREW_CACHE}/api/${formula_or_cask}.json") + time_cond=("--time-cond" "${HOMEBREW_CACHE}/api/${formula_or_cask}.jws.json") fi curl \ "${CURL_DISABLE_CURLRC_ARGS[@]}" \ --fail --compressed --silent \ --speed-limit "${HOMEBREW_CURL_SPEED_LIMIT}" --speed-time "${HOMEBREW_CURL_SPEED_TIME}" \ - --location --remote-time --output "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" \ + --location --remote-time --output "${HOMEBREW_CACHE}/api/${formula_or_cask}.jws.json" \ "${time_cond[@]}" \ --user-agent "${HOMEBREW_USER_AGENT_CURL}" \ "${json_url}" @@ -796,8 +796,8 @@ EOS done if [[ ${curl_exit_code} -eq 0 ]] then - touch "${HOMEBREW_CACHE}/api/${formula_or_cask}.json" - CURRENT_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/"${formula_or_cask}".json)" + touch "${HOMEBREW_CACHE}/api/${formula_or_cask}.jws.json" + CURRENT_JSON_BYTESIZE="$(wc -c "${HOMEBREW_CACHE}"/api/"${formula_or_cask}".jws.json)" if [[ "${INITIAL_JSON_BYTESIZE}" != "${CURRENT_JSON_BYTESIZE}" ]] then rm -f "${HOMEBREW_CACHE}/api/${formula_or_cask}_names.txt" diff --git a/Library/Homebrew/test/api/cask_spec.rb b/Library/Homebrew/test/api/cask_spec.rb index 36e08b7076..b1380216b6 100644 --- a/Library/Homebrew/test/api/cask_spec.rb +++ b/Library/Homebrew/test/api/cask_spec.rb @@ -9,12 +9,16 @@ describe Homebrew::API::Cask do before do stub_const("Homebrew::API::HOMEBREW_CACHE_API", cache_dir) Homebrew::API.clear_cache + described_class.clear_cache end def mock_curl_download(stdout:) allow(Utils::Curl).to receive(:curl_download) do |*_args, **kwargs| kwargs[:to].write stdout end + allow(Homebrew::API).to receive(:verify_and_parse_jws) do |json_data| + [true, json_data] + end end describe "::all_casks" do diff --git a/Library/Homebrew/test/api/formula_spec.rb b/Library/Homebrew/test/api/formula_spec.rb index 38dbb57d5f..dbbb2da6ba 100644 --- a/Library/Homebrew/test/api/formula_spec.rb +++ b/Library/Homebrew/test/api/formula_spec.rb @@ -8,12 +8,16 @@ describe Homebrew::API::Formula do before do stub_const("Homebrew::API::HOMEBREW_CACHE_API", cache_dir) + described_class.clear_cache end def mock_curl_download(stdout:) allow(Utils::Curl).to receive(:curl_download) do |*_args, **kwargs| kwargs[:to].write stdout end + allow(Homebrew::API).to receive(:verify_and_parse_jws) do |json_data| + [true, json_data] + end end describe "::all_formulae" do