diff options
-rw-r--r-- | Gemfile | 1 | ||||
-rw-r--r-- | lib/mixlib/authentication/signedheaderauth.rb | 89 | ||||
-rw-r--r-- | spec/mixlib/authentication/mixlib_authentication_spec.rb | 27 |
3 files changed, 109 insertions, 8 deletions
@@ -4,4 +4,5 @@ gemspec group(:development) do gem "pry" gem "mixlib-log", "~> 2" + gem "net-ssh" end diff --git a/lib/mixlib/authentication/signedheaderauth.rb b/lib/mixlib/authentication/signedheaderauth.rb index 6b9b850..dcc9ee1 100644 --- a/lib/mixlib/authentication/signedheaderauth.rb +++ b/lib/mixlib/authentication/signedheaderauth.rb @@ -95,9 +95,27 @@ module Mixlib # Build the canonicalized request based on the method, other headers, etc. # compute the signature from the request, using the looked-up user secret - # ====Parameters - # private_key<OpenSSL::PKey::RSA>:: user's RSA private key. - def sign(private_key, sign_algorithm = algorithm, sign_version = proto_version) + # + # @param rsa_key [OpenSSL::PKey::RSA] User's RSA key. If `use_ssh_agent` is + # true, this must have the public key portion populated. If `use_ssh_agent` + # is false, this must have the private key portion populated. + # @param use_ssh_agent [Boolean] If true, use ssh-agent for request signing. + def sign(rsa_key, sign_algorithm = algorithm, sign_version = proto_version, **opts) + # Backwards compat stuff. + if sign_algorithm.is_a?(Hash) + # Was called like sign(key, sign_algorithm: 'foo', other: 'bar') + opts.update(sign_algorithm) + opts[:sign_algorithm] ||= algorithm + else + # Was called like sign(key, 'foo', '1.3', other: 'bar') + Mixlib::Authentication.logger.warn("Using deprecated positional arguments for sign(), please update to keyword arguments (from #{caller[1][/^(.*:\d+):in /, 1]})") + opts[:sign_algorithm] ||= sign_algorithm + opts[:sign_version] ||= sign_version + end + sign_algorithm = opts[:sign_algorithm] + sign_version = opts[:sign_version] + use_ssh_agent = opts[:use_ssh_agent] + digest = validate_sign_version_digest!(sign_algorithm, sign_version) # Our multiline hash for authorization will be encoded in multiple header # lines - X-Ops-Authorization-1, ... (starts at 1, not 0!) @@ -108,7 +126,7 @@ module Mixlib "X-Ops-Content-Hash" => hashed_body(digest), } - signature = Base64.encode64(do_sign(private_key, digest, sign_algorithm, sign_version)).chomp + signature = Base64.encode64(do_sign(rsa_key, digest, sign_algorithm, sign_version, use_ssh_agent)).chomp signature_lines = signature.split(/\n/) signature_lines.each_index do |idx| key = "X-Ops-Authorization-#{idx + 1}" @@ -244,18 +262,73 @@ module Mixlib Mixlib::Authentication::Digester end - # private - def do_sign(private_key, digest, sign_algorithm, sign_version) + # Low-level RSA signature implementation used in {#sign}. + # + # @api private + # @param rsa_key [OpenSSL::PKey::RSA] User's RSA key. If `use_ssh_agent` is + # true, this must have the public key portion populated. If `use_ssh_agent` + # is false, this must have the private key portion populated. + # @param digest [Class] Sublcass of OpenSSL::Digest to use while signing. + # @param sign_algorithm [String] Hash algorithm to use while signing. + # @param sign_version [String] Version number of the signing protocol to use. + # @param use_ssh_agent [Boolean] If true, use ssh-agent for request signing. + # @return [String] + def do_sign(rsa_key, digest, sign_algorithm, sign_version, use_ssh_agent) string_to_sign = canonicalize_request(sign_algorithm, sign_version) Mixlib::Authentication.logger.trace "String to sign: '#{string_to_sign}'" case sign_version when "1.3" - private_key.sign(digest.new, string_to_sign) + if use_ssh_agent + do_sign_ssh_agent(rsa_key, string_to_sign) + else + raise AuthenticationError, "RSA private key is required to sign requests, but a public key was provided" unless rsa_key.private? + rsa_key.sign(digest.new, string_to_sign) + end else - private_key.private_encrypt(string_to_sign) + raise AuthenticationError, "Agent signing mode requires signing protocol version 1.3 or newer" if use_ssh_agent + raise AuthenticationError, "RSA private key is required to sign requests, but a public key was provided" unless rsa_key.private? + rsa_key.private_encrypt(string_to_sign) end end + # Low-level signing logic for using ssh-agent. This requires the user has + # already set up ssh-agent and used ssh-add to load in a (possibly encrypted) + # RSA private key. ssh-agent supports keys other than RSA, however they + # are not supported as Chef's protocol explicitly requires RSA keys/sigs. + # + # @api private + # @param rsa_key [OpenSSL::PKey::RSA] User's RSA public key. + # @param string_to_sign [String] String data to sign with the requested key. + # @return [String] + def do_sign_ssh_agent(rsa_key, string_to_sign) + # First try loading net-ssh as it is an optional dependency. + begin + require "net/ssh" + rescue LoadError => e + # ???: Since agent mode is explicitly enabled, should we even catch + # this in the first place? Might be cleaner to let the LoadError bubble. + raise AuthenticationError, "net-ssh gem is not available, unable to use ssh-agent signing: #{e.message}" + end + + # Try to connect to ssh-agent. + begin + agent = Net::SSH::Authentication::Agent.connect + rescue Net::SSH::Authentication::AgentNotAvailable => e + raise AuthenticationError, "Could not connect to ssh-agent. Make sure the SSH_AUTH_SOCK environment variable is set and ssh-agent is running: #{e.message}" + end + + begin + ssh2_signature = agent.sign(rsa_key.public_key, string_to_sign, Net::SSH::Authentication::Agent::SSH_AGENT_RSA_SHA2_256) + rescue Net::SSH::Authentication::AgentError => e + raise AuthenticationError, "Unable to sign request with ssh-agent. Make sure your key is loaded with ssh-add: #{e.class.name} #{e.message})" + end + + # extract signature from SSH Agent response => skip first 20 bytes for RSA keys + # "\x00\x00\x00\frsa-sha2-256\x00\x00\x01\x00" + # (see http://api.libssh.org/rfc/PROTOCOL.agent for details) + ssh2_signature[20..-1] + end + private :canonical_time, :canonical_path, :parse_signing_description, :digester, :canonicalize_user_id end diff --git a/spec/mixlib/authentication/mixlib_authentication_spec.rb b/spec/mixlib/authentication/mixlib_authentication_spec.rb index 4b2204f..2c233a2 100644 --- a/spec/mixlib/authentication/mixlib_authentication_spec.rb +++ b/spec/mixlib/authentication/mixlib_authentication_spec.rb @@ -25,6 +25,7 @@ require "ostruct" require "openssl" require "mixlib/authentication/signatureverification" require "time" +require "net/ssh" # TODO: should make these regular spec-based mock objects. class MockRequest @@ -101,6 +102,13 @@ describe "Mixlib::Authentication::SignedHeaderAuth" do expect(V1_3_SHA256_SIGNING_OBJECT.sign(PRIVATE_KEY)).to eq(EXPECTED_SIGN_RESULT_V1_3_SHA256) end + it "should generate the correct string to sign and signature for version 1.3 with SHA256 via ssh-agent" do + agent = double("ssh-agent") + expect(Net::SSH::Authentication::Agent).to receive(:connect).and_return(agent) + expect(agent).to receive(:sign).and_return(SSH_AGENT_RESPONSE) + expect(V1_3_SHA256_SIGNING_OBJECT.sign(PUBLIC_KEY, use_ssh_agent: true)).to eq(EXPECTED_SIGN_RESULT_V1_3_SHA256) + end + it "should generate the correct string to sign and signature for non-default proto version when used as a mixin" do algorithm = "sha1" version = "1.1" @@ -113,14 +121,17 @@ describe "Mixlib::Authentication::SignedHeaderAuth" do # the results of res.inspect and copy them as appropriate into the # the constants in this file. expect(V1_1_SIGNING_OBJECT.sign(PRIVATE_KEY, algorithm, version)).to eq(EXPECTED_SIGN_RESULT_V1_1) + expect(V1_1_SIGNING_OBJECT.sign(PRIVATE_KEY, sign_algorithm: algorithm, sign_version: version)).to eq(EXPECTED_SIGN_RESULT_V1_1) end it "should not choke when signing a request for a long user id with version 1.1" do expect { LONG_SIGNING_OBJECT.sign(PRIVATE_KEY, "sha1", "1.1") }.not_to raise_error + expect { LONG_SIGNING_OBJECT.sign(PRIVATE_KEY, sign_algorithm: "sha1", sign_version: "1.1") }.not_to raise_error end it "should choke when signing a request for a long user id with version 1.0" do expect { LONG_SIGNING_OBJECT.sign(PRIVATE_KEY, "sha1", "1.0") }.to raise_error(OpenSSL::PKey::RSAError) + expect { LONG_SIGNING_OBJECT.sign(PRIVATE_KEY, sign_algorithm: "sha1", sign_version: "1.0") }.to raise_error(OpenSSL::PKey::RSAError) end it "should choke when signing a request with a bad version" do @@ -131,6 +142,18 @@ describe "Mixlib::Authentication::SignedHeaderAuth" do expect { V1_1_SIGNING_OBJECT.sign(PRIVATE_KEY, "sha_poo", "1.1") }.to raise_error(Mixlib::Authentication::AuthenticationError) end + it "should choke when signing a request via ssh-agent and ssh-agent is not reachable with version 1.3" do + expect(Net::SSH::Authentication::Agent).to receive(:connect).and_raise(Net::SSH::Authentication::AgentNotAvailable) + expect { V1_3_SHA256_SIGNING_OBJECT.sign(PUBLIC_KEY, use_ssh_agent: true) }.to raise_error(Mixlib::Authentication::AuthenticationError) + end + + it "should choke when signing a request via ssh-agent and the key is not loaded with version 1.3" do + agent = double("ssh-agent") + expect(Net::SSH::Authentication::Agent).to receive(:connect).and_return(agent) + expect(agent).to receive(:sign).and_raise(Net::SSH::Authentication::AgentError) + expect { V1_3_SHA256_SIGNING_OBJECT.sign(PUBLIC_KEY, use_ssh_agent: true) }.to raise_error(Mixlib::Authentication::AuthenticationError) + end + end describe "Mixlib::Authentication::SignatureVerification" do @@ -366,6 +389,8 @@ X_OPS_AUTHORIZATION_LINES_V1_3_SHA256 = [ "MmbLUIm3JRYi00Yb01IUCCKdI90vUq1HHNtlTEu93YZfQaJwRxXlGkCNwIJe", "fy49QzaCIEu1XiOx5Jn+4GmkrZch/RrK9VzQWXgs+w==", ] + +SSH_AGENT_RESPONSE = "\x00\x00\x00\frsa-sha2-256\x00\x00\x01\x00\x15\x93\xA6\\\f\x8E\x04\x06PW\xFB\xB0\xD7\xCF\"\x06X\xC1%s\xA6\xFAo1C\xFF\nLb\xE4\x80l\x195\xC4E\xC6Mf\xF7\x9D\xD7\x8CM\xD6Tl\xB5tT\xFB\xE8\xA7\x9A5i\x8F\b\xDBC\x9A\x9A\xDF\x1Fi\xDA\xE5FE\xB5\xF2\xC8*\xB3\xEF\xEF\x19\xBC\xD1_\xA5\xCCL\xD3w\xD5\x81\xC2\xC7\xCF\xE3gY\xF4\xDF\x95\xF4\x8ERU\xF7\v\xFEU\xAB\xAEZ]\xC9\xB7\xDCx\x90\xB9\x8C\xE7\x0F\xE6\xDC\xDF%u\x94!<\e\xE9\x9D\xC4\xAE\r\xC3Su!\x1F\xD8}\x13J\x96\x95\x81\xAA\x9A#BV\xB0g\xA5\xEE\x92\x8BX\x14\xFC\x99~\xADyQH\xD6\xCB'\x81\xA5\x02\xB0\x0F\xB8\xBF{\xEA$\xD8%<\xC42f\xCBP\x89\xB7%\x16\"\xD3F\e\xD3R\x14\b\"\x9D#\xDD/R\xADG\x1C\xDBeLK\xBD\xDD\x86_A\xA2pG\x15\xE5\x1A@\x8D\xC0\x82^\x7F.=C6\x82 K\xB5^#\xB1\xE4\x99\xFE\xE0i\xA4\xAD\x97!\xFD\x1A\xCA\xF5\\\xD0Yx,\xFB" # We expect Mixlib::Authentication::SignedHeaderAuth#sign to return this # if passed the BODY above, based on version @@ -529,6 +554,8 @@ YQIDAQAB -----END PUBLIC KEY----- EOS +PUBLIC_KEY = OpenSSL::PKey::RSA.new(PUBLIC_KEY_DATA) + PRIVATE_KEY_DATA = <<EOS -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA0ueqo76MXuP6XqZBILFziH/9AI7C6PaN5W0dSvkr9yInyGHS |